Merge branch 'main' into feature/negodata
Helped-By: Claude Opus 4.8뀨 <noreply@anthropic.com>
This commit is contained in:
commit
3176ec4262
@ -111,10 +111,10 @@ class sessions(MAIN_BASE):
|
|||||||
qt_round = Column(Integer, nullable=False) # 견적 라운드(스냅샷)
|
qt_round = Column(Integer, nullable=False) # 견적 라운드(스냅샷)
|
||||||
qt_type = Column(SmallInteger, nullable=False) # 견적 유형: 1=재협상, 2=재견적, 3=신규협상, 4=신규견적 (QtType)
|
qt_type = Column(SmallInteger, nullable=False) # 견적 유형: 1=재협상, 2=재견적, 3=신규협상, 4=신규견적 (QtType)
|
||||||
target_price = Column(BigInteger, nullable=False) # 목표가(원)
|
target_price = Column(BigInteger, nullable=False) # 목표가(원)
|
||||||
target_anchoring_price = Column(BigInteger, nullable=True) # 앵커링가(원) — 생성 시 박제(negodata), 사후 수정 금지
|
anchoring_price = Column(BigInteger, nullable=True) # 앵커링가(원) — 생성 시 박제(negodata), 사후 수정 금지
|
||||||
anchor_rate_permille = Column(SmallInteger, nullable=True) # 제안 당시 앵커링 값(‰) 박제 — 사후 수정 금지
|
anchoring_value = Column(SmallInteger, nullable=True) # 제안 당시 앵커링 값(‰) 박제 — 사후 수정 금지
|
||||||
last_offered_price = Column(BigInteger, nullable=True) # 협력사 마지막 제시가(원) — 가격 입력마다 갱신, 종료 후 불변. 앵커링 표본 판정의 "가격 흔적"
|
last_offer_price = Column(BigInteger, nullable=True) # 협력사 마지막 제시가(원) — 가격 입력마다 갱신, 종료 후 불변. 앵커링 표본 판정의 "가격 흔적"
|
||||||
anchoring_adjustment_id = Column(BigInteger, nullable=True) # 앵커링 배치 소비 마킹(NULL=미처리 0=제외 >0=조정 id) — schedules/anchoring 전용
|
used_by_adjustment_id = Column(BigInteger, nullable=True) # 앵커링 배치 소비 마킹(NULL=미처리 0=제외 >0=조정 id) — schedules/anchoring 전용
|
||||||
status = Column(SmallInteger, nullable=False) # 진행 상태 (SessionStatus 코드)
|
status = Column(SmallInteger, nullable=False) # 진행 상태 (SessionStatus 코드)
|
||||||
bid_price = Column(BigInteger, nullable=True) # 입찰가(원)
|
bid_price = Column(BigInteger, nullable=True) # 입찰가(원)
|
||||||
bid_at = Column(DateTime(timezone=True), nullable=True) # 입찰 시각
|
bid_at = Column(DateTime(timezone=True), nullable=True) # 입찰 시각
|
||||||
@ -165,7 +165,7 @@ class quotations(MAIN_BASE):
|
|||||||
|
|
||||||
|
|
||||||
class quotation_settings(MAIN_BASE):
|
class quotation_settings(MAIN_BASE):
|
||||||
# quotation.quotation_settings (견적 설정). 견적 설정 스냅샷 — anchoring_value 는 구(舊) 앵커 산출용으로 채팅 경로에서는 더 이상 사용하지 않음(앵커는 sessions.target_anchoring_price 박제값).
|
# quotation.quotation_settings (견적 설정). 견적 설정 스냅샷 — anchoring_value 는 구(舊) 앵커 산출용으로 채팅 경로에서는 더 이상 사용하지 않음(앵커는 sessions.anchoring_price 박제값).
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def DBType():
|
def DBType():
|
||||||
return DBType.QUOTATION.value
|
return DBType.QUOTATION.value
|
||||||
|
|||||||
@ -44,7 +44,7 @@ class IChatCRUD(ABC):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def update_last_offered_price(self, cdb: AsyncSession, session_id, price: int) -> ErrorType:
|
async def update_last_offer_price(self, cdb: AsyncSession, session_id, price: int) -> ErrorType:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@ -136,7 +136,7 @@ class ChatCRUD(IChatCRUD):
|
|||||||
LOG.e_no_callstack(ex)
|
LOG.e_no_callstack(ex)
|
||||||
return ErrorType.DB_RUN_FAILED
|
return ErrorType.DB_RUN_FAILED
|
||||||
|
|
||||||
async def update_last_offered_price(self, cdb: AsyncSession, session_id, price: int) -> ErrorType:
|
async def update_last_offer_price(self, cdb: AsyncSession, session_id, price: int) -> ErrorType:
|
||||||
"""협력사 마지막 제시가 갱신 — 가격 입력 턴의 봇 메시지 저장과 같은 트랜잭션에서 호출.
|
"""협력사 마지막 제시가 갱신 — 가격 입력 턴의 봇 메시지 저장과 같은 트랜잭션에서 호출.
|
||||||
|
|
||||||
진행 중엔 매 가격 입력마다 덮어쓰고 종료 후엔 불변. 앵커링 표본 판정에서
|
진행 중엔 매 가격 입력마다 덮어쓰고 종료 후엔 불변. 앵커링 표본 판정에서
|
||||||
@ -147,7 +147,7 @@ class ChatCRUD(IChatCRUD):
|
|||||||
query = (
|
query = (
|
||||||
update(sessions)
|
update(sessions)
|
||||||
.where(sessions.session_id == session_id, sessions.status == SessionStatus.IN_PROGRESS.value)
|
.where(sessions.session_id == session_id, sessions.status == SessionStatus.IN_PROGRESS.value)
|
||||||
.values(last_offered_price=price)
|
.values(last_offer_price=price)
|
||||||
)
|
)
|
||||||
return await DB_SESSION_MNG.add(cdb, query)
|
return await DB_SESSION_MNG.add(cdb, query)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
|
|||||||
@ -43,7 +43,7 @@ class AgentChatContext:
|
|||||||
tenant_id: str # X-Tenant-ID = 견적(갑) 회사 company_id
|
tenant_id: str # X-Tenant-ID = 견적(갑) 회사 company_id
|
||||||
rq_type: str = "재협상" # 재협상 | 재견적
|
rq_type: str = "재협상" # 재협상 | 재견적
|
||||||
target_price: int = 0 # 갑 목표 매입가(원)
|
target_price: int = 0 # 갑 목표 매입가(원)
|
||||||
anchor_price: int = 0 # 앵커링가(목표가보다 낮음). 세션 생성 시 박제된 sessions.target_anchoring_price.
|
anchor_price: int = 0 # 앵커링가(목표가보다 낮음). 세션 생성 시 박제된 sessions.anchoring_price.
|
||||||
item_price: int = 0 # 기존 공급가(품목 기준가). agent 가격협상_확인 인하율 산출용.
|
item_price: int = 0 # 기존 공급가(품목 기준가). agent 가격협상_확인 인하율 산출용.
|
||||||
# 핸드오프 #4: agent 의 RL 상태(state) 계산 입력.
|
# 핸드오프 #4: agent 의 RL 상태(state) 계산 입력.
|
||||||
# partner_count 는 견적당 세션 수로 산출(실데이터). 나머지 3개는 우리 스키마에 데이터 소스가 없어
|
# partner_count 는 견적당 세션 수로 산출(실데이터). 나머지 3개는 우리 스키마에 데이터 소스가 없어
|
||||||
|
|||||||
@ -369,7 +369,7 @@ class ChatService:
|
|||||||
# 가격 입력 턴 → 마지막 제시가를 봇 메시지 저장과 같은 트랜잭션으로 갱신.
|
# 가격 입력 턴 → 마지막 제시가를 봇 메시지 저장과 같은 트랜잭션으로 갱신.
|
||||||
# 앵커링 표본 판정의 "가격 흔적"(가격을 써낸 협상만 집계 — 중간 이탈해도 실패로 측정 가능).
|
# 앵커링 표본 판정의 "가격 흔적"(가격을 써낸 협상만 집계 — 중간 이탈해도 실패로 측정 가능).
|
||||||
if price is not None:
|
if price is not None:
|
||||||
funcs.append(lambda s: self.chat_crud.update_last_offered_price(s, sess.session_id, price))
|
funcs.append(lambda s: self.chat_crud.update_last_offer_price(s, sess.session_id, price))
|
||||||
new_status = sess.status
|
new_status = sess.status
|
||||||
if turn.chat_end:
|
if turn.chat_end:
|
||||||
if turn.outcome == "success":
|
if turn.outcome == "success":
|
||||||
@ -419,7 +419,7 @@ class ChatService:
|
|||||||
LOG.w(f"[chat] tenant_id 해석 실패(item.company_id 없음) session_id={sess.session_id} — agent 400 위험")
|
LOG.w(f"[chat] tenant_id 해석 실패(item.company_id 없음) session_id={sess.session_id} — agent 400 위험")
|
||||||
rq_type = "재협상" if sess.qt_type == 1 else "재견적"
|
rq_type = "재협상" if sess.qt_type == 1 else "재견적"
|
||||||
target_price = int(sess.target_price or 0)
|
target_price = int(sess.target_price or 0)
|
||||||
# 앵커가: 세션 생성 시 박제된 값(target_anchoring_price)을 그대로 사용 — 협상 중 불변.
|
# 앵커가: 세션 생성 시 박제된 값(anchoring_price)을 그대로 사용 — 협상 중 불변.
|
||||||
anchor = await self._resolve_anchor_price(sess, target_price)
|
anchor = await self._resolve_anchor_price(sess, target_price)
|
||||||
# 공급사 수: 같은 견적에 속한 세션 수(재협상=1, 재견적=N). agent partner 차원(single/multiple/none) 입력.
|
# 공급사 수: 같은 견적에 속한 세션 수(재협상=1, 재견적=N). agent partner 차원(single/multiple/none) 입력.
|
||||||
partner_count = await self._count_partners(sess)
|
partner_count = await self._count_partners(sess)
|
||||||
@ -434,7 +434,7 @@ class ChatService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def _resolve_anchor_price(self, sess, target_price: int) -> int:
|
async def _resolve_anchor_price(self, sess, target_price: int) -> int:
|
||||||
"""세션에 박제된 앵커가(target_anchoring_price — negodata 가 생성 시 기록)를 그대로 사용.
|
"""세션에 박제된 앵커가(anchoring_price — negodata 가 생성 시 기록)를 그대로 사용.
|
||||||
|
|
||||||
박제값 사용이 정상 경로다: 협상 진행 중 앵커링 배치 조정·재기동이 껴도 앵커가 흔들리지 않는다
|
박제값 사용이 정상 경로다: 협상 진행 중 앵커링 배치 조정·재기동이 껴도 앵커가 흔들리지 않는다
|
||||||
("제안 당시 값" 판정의 전제 — schedules/anchoring/docs/개발용.md §9.2). backend 는 앵커를 계산하지 않는다.
|
("제안 당시 값" 판정의 전제 — schedules/anchoring/docs/개발용.md §9.2). backend 는 앵커를 계산하지 않는다.
|
||||||
@ -443,8 +443,8 @@ class ChatService:
|
|||||||
"""
|
"""
|
||||||
if not target_price:
|
if not target_price:
|
||||||
return 0
|
return 0
|
||||||
if sess.target_anchoring_price is not None:
|
if sess.anchoring_price is not None:
|
||||||
return int(sess.target_anchoring_price)
|
return int(sess.anchoring_price)
|
||||||
LOG.w(f"[chat] 앵커가 박제 없음 session_id={sess.session_id} — 무할인 폴백(anchor=target), 집계 제외")
|
LOG.w(f"[chat] 앵커가 박제 없음 session_id={sess.session_id} — 무할인 폴백(anchor=target), 집계 제외")
|
||||||
return target_price
|
return target_price
|
||||||
|
|
||||||
|
|||||||
@ -110,7 +110,7 @@ async def anchor_seed(db_engine):
|
|||||||
await conn.execute(
|
await conn.execute(
|
||||||
text("INSERT INTO negotiation.sessions "
|
text("INSERT INTO negotiation.sessions "
|
||||||
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, "
|
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, "
|
||||||
" target_price, target_anchoring_price, anchor_rate_permille, status, end_time) "
|
" target_price, anchoring_price, anchoring_value, status, end_time) "
|
||||||
"VALUES (:sesid, :qid, :iid, :sup, :qtn, 1, 1, :tp, :ap, :rate, 2, now() + interval '2 hours')"),
|
"VALUES (:sesid, :qid, :iid, :sup, :qtn, 1, 1, :tp, :ap, :rate, 2, now() + interval '2 hours')"),
|
||||||
{"sesid": session_id, "qid": qt_id, "iid": item_id, "sup": supplier_id,
|
{"sesid": session_id, "qid": qt_id, "iid": item_id, "sup": supplier_id,
|
||||||
"qtn": f"{MARK}{code}", "tp": TARGET, "ap": anchor, "rate": rate},
|
"qtn": f"{MARK}{code}", "tp": TARGET, "ap": anchor, "rate": rate},
|
||||||
@ -143,7 +143,7 @@ async def _send(client, token, sid, user_input, user_input_type=None):
|
|||||||
async def _anchor_columns(db_engine, session_id):
|
async def _anchor_columns(db_engine, session_id):
|
||||||
async with db_engine.begin() as conn:
|
async with db_engine.begin() as conn:
|
||||||
row = (await conn.execute(text(
|
row = (await conn.execute(text(
|
||||||
"SELECT target_anchoring_price, anchor_rate_permille, last_offered_price, bid_price "
|
"SELECT anchoring_price, anchoring_value, last_offer_price, bid_price "
|
||||||
"FROM negotiation.sessions WHERE session_id = :sid"), {"sid": session_id})).one()
|
"FROM negotiation.sessions WHERE session_id = :sid"), {"sid": session_id})).one()
|
||||||
return row
|
return row
|
||||||
|
|
||||||
@ -158,21 +158,21 @@ async def test_snapshot_consumed_and_last_offer_recorded(client, db_engine, anch
|
|||||||
r = await _send(client, token, sid, "네, 시작할게요") # → 가격 입력 요청 (아직 가격 흔적 없음)
|
r = await _send(client, token, sid, "네, 시작할게요") # → 가격 입력 요청 (아직 가격 흔적 없음)
|
||||||
assert r.status_code == 200 and r.json()["message"]["step"] == "기존가격제시"
|
assert r.status_code == 200 and r.json()["message"]["step"] == "기존가격제시"
|
||||||
row = await _anchor_columns(db_engine, sid)
|
row = await _anchor_columns(db_engine, sid)
|
||||||
assert row.last_offered_price is None
|
assert row.last_offer_price is None
|
||||||
assert _fake_agent.seen_anchors[-1] == ANCHOR # backend 가 박제값을 그대로 전달
|
assert _fake_agent.seen_anchors[-1] == ANCHOR # backend 가 박제값을 그대로 전달
|
||||||
|
|
||||||
r = await _send(client, token, sid, "99,500", "price") # 앵커 초과 → 같은 step 반복
|
r = await _send(client, token, sid, "99,500", "price") # 앵커 초과 → 같은 step 반복
|
||||||
assert r.json()["message"]["step"] == "기존가격제시"
|
assert r.json()["message"]["step"] == "기존가격제시"
|
||||||
row = await _anchor_columns(db_engine, sid)
|
row = await _anchor_columns(db_engine, sid)
|
||||||
assert row.last_offered_price == 99_500 # 가격 흔적 기록
|
assert row.last_offer_price == 99_500 # 가격 흔적 기록
|
||||||
# 이 시점에 이탈해 일괄마감(NOT_PARTICIPATED)돼도 last_offered_price 로 실패 표본이 된다.
|
# 이 시점에 이탈해 일괄마감(NOT_PARTICIPATED)돼도 last_offer_price 로 실패 표본이 된다.
|
||||||
|
|
||||||
r = await _send(client, token, sid, "98,000", "price") # 앵커 이하 → 합의 종료
|
r = await _send(client, token, sid, "98,000", "price") # 앵커 이하 → 합의 종료
|
||||||
assert r.json()["session_status"] == 3 # DONE
|
assert r.json()["session_status"] == 3 # DONE
|
||||||
row = await _anchor_columns(db_engine, sid)
|
row = await _anchor_columns(db_engine, sid)
|
||||||
assert row.last_offered_price == 98_000 # 마지막 값으로 갱신
|
assert row.last_offer_price == 98_000 # 마지막 값으로 갱신
|
||||||
assert row.bid_price == 98_000
|
assert row.bid_price == 98_000
|
||||||
assert (row.target_anchoring_price, row.anchor_rate_permille) == (ANCHOR, 10) # 박제 불변
|
assert (row.anchoring_price, row.anchoring_value) == (ANCHOR, 10) # 박제 불변
|
||||||
|
|
||||||
|
|
||||||
# ── 폴백 경로: 박제 NULL → 무할인(anchor=target) + 미박제 유지 ──
|
# ── 폴백 경로: 박제 NULL → 무할인(anchor=target) + 미박제 유지 ──
|
||||||
@ -188,6 +188,6 @@ async def test_null_snapshot_falls_back_to_target(client, db_engine, anchor_seed
|
|||||||
r = await _send(client, token, sid, "97,000", "price") # 가격 입력(폴백 앵커 이하 → 종료)
|
r = await _send(client, token, sid, "97,000", "price") # 가격 입력(폴백 앵커 이하 → 종료)
|
||||||
assert r.json()["session_status"] == 3
|
assert r.json()["session_status"] == 3
|
||||||
row = await _anchor_columns(db_engine, sid)
|
row = await _anchor_columns(db_engine, sid)
|
||||||
assert row.target_anchoring_price is None # backend 는 박제하지 않음(앵커 없음 → 집계 제외)
|
assert row.anchoring_price is None # backend 는 박제하지 않음(앵커 없음 → 집계 제외)
|
||||||
assert row.anchor_rate_permille is None
|
assert row.anchoring_value is None
|
||||||
assert row.last_offered_price == 97_000 # 가격 흔적 기록은 정상 동작
|
assert row.last_offer_price == 97_000 # 가격 흔적 기록은 정상 동작
|
||||||
|
|||||||
@ -8,11 +8,13 @@
|
|||||||
# negosium 프론트: http://localhost:3300
|
# negosium 프론트: http://localhost:3300
|
||||||
# negodata 서버: http://localhost:9400/docs
|
# negodata 서버: http://localhost:9400/docs
|
||||||
# agent 서버: http://localhost:9500/docs
|
# agent 서버: http://localhost:9500/docs
|
||||||
|
# anchoring 배치: 포트 없음 — 상주 스케줄러(격주 토 00:00 KST), docker logs anchoring 으로 확인
|
||||||
#
|
#
|
||||||
# DB 준비(최초 1회): postgres-init 의 SQL 을 대상 DB 에 적용한다.
|
# DB 준비(최초 1회): postgres-init 의 SQL 을 대상 DB 에 적용한다.
|
||||||
# psql -h <host> -p <port> -U <user> -f postgres-init/01-schema*.sql (단일 negosium_db + 도메인별 schema)
|
# psql -h <host> -p <port> -U <user> -f postgres-init/01-schema*.sql (단일 negosium_db + 도메인별 schema)
|
||||||
# psql -h <host> -p <port> -U <user> -f postgres-init/02-learning-schema.sql (agent learning 스키마)
|
# psql -h <host> -p <port> -U <user> -f postgres-init/02-learning-schema.sql (agent learning 스키마)
|
||||||
# psql -h <host> -p <port> -U <user> -f postgres-init/03-seed-negodata.sql (negodata 전용 시드: admin / admin1234, company.users)
|
# psql -h <host> -p <port> -U <user> -f postgres-init/03-seed-negodata.sql (negodata 전용 시드: admin / admin1234, company.users)
|
||||||
|
# psql -h <host> -p <port> -U <user> -f postgres-init/05-anchoring-schema.sql (anchoring 스키마: adjustments·뷰 — schedules/anchoring 소유)
|
||||||
|
|
||||||
services:
|
services:
|
||||||
negosium-backend:
|
negosium-backend:
|
||||||
@ -77,3 +79,38 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3300:3300"
|
- "3300:3300"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# 앵커링 값 자동 조정 배치 (negosium_db 공유, 포트 없음 — 상주 스케줄러).
|
||||||
|
anchoring:
|
||||||
|
build: ./schedules/anchoring
|
||||||
|
container_name: anchoring
|
||||||
|
environment:
|
||||||
|
APP_ENV: local # config.{APP_ENV}.toml 선택 (local/dev/prod)
|
||||||
|
DB_HOST: host.docker.internal # 컨테이너→호스트 DB
|
||||||
|
REDIS_HOST: anchoring-redis
|
||||||
|
TZ: Asia/Seoul
|
||||||
|
volumes:
|
||||||
|
- ./schedules/anchoring/config.local.toml:/app/config.local.toml:ro # 시크릿은 마운트 — up 전에 파일 필요
|
||||||
|
depends_on:
|
||||||
|
- anchoring-redis
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
restart: unless-stopped
|
||||||
|
logging: # 상주 배치 — 장기 운영 디스크 보호
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "5"
|
||||||
|
|
||||||
|
# 앵커링 조회 캐시 (anchoring 전용)
|
||||||
|
anchoring-redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: anchoring-redis
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:6380:6379" # 호스트 로컬만 개방 (무인증 Redis). 6380 = 호스트 redis(6379)와 충돌 회피
|
||||||
|
restart: unless-stopped
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "5"
|
||||||
|
|||||||
@ -7,16 +7,16 @@
|
|||||||
값 조정(격주 배치)·표본 판정은 이식 대상이 아니다(schedules/anchoring 서비스 담당).
|
값 조정(격주 배치)·표본 판정은 이식 대상이 아니다(schedules/anchoring 서비스 담당).
|
||||||
상수·계산식을 고칠 일이 생기면 원본 모듈과 반드시 함께 고친다(단독 수정 금지).
|
상수·계산식을 고칠 일이 생기면 원본 모듈과 반드시 함께 고친다(단독 수정 금지).
|
||||||
"""
|
"""
|
||||||
from common.anchoring.base_table import get_base_rate_permille, load_base_table
|
from common.anchoring.base_table import get_base_anchoring_value, load_base_table
|
||||||
from common.anchoring.constants import SAMPLEABLE_SUPPLIER_TYPES
|
from common.anchoring.constants import SAMPLEABLE_SUPPLIER_TYPES
|
||||||
from common.anchoring.reader import fetch_current_rates
|
from common.anchoring.reader import fetch_current_values
|
||||||
from common.anchoring.service import calc_anchor_price, calc_bracket_index
|
from common.anchoring.service import calc_anchoring_price, calc_price_range_index
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"SAMPLEABLE_SUPPLIER_TYPES",
|
"SAMPLEABLE_SUPPLIER_TYPES",
|
||||||
"calc_anchor_price",
|
"calc_anchoring_price",
|
||||||
"calc_bracket_index",
|
"calc_price_range_index",
|
||||||
"fetch_current_rates",
|
"fetch_current_values",
|
||||||
"get_base_rate_permille",
|
"get_base_anchoring_value",
|
||||||
"load_base_table",
|
"load_base_table",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -7,11 +7,11 @@ DB 에 저장하지 않으며 런타임에 절대 수정하지 않는다. 검증
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from common.anchoring.constants import BRACKET_COUNT, UPPER_BOUNDS
|
from common.anchoring.constants import PRICE_RANGE_COUNT, UPPER_BOUNDS
|
||||||
|
|
||||||
_RESOURCE = Path(__file__).parent / "resources" / "anchoring_base.json"
|
_RESOURCE = Path(__file__).parent / "resources" / "anchoring_base.json"
|
||||||
|
|
||||||
_rates: list[int] | None = None # bracket_index → 시작값(‰)
|
_rates: list[int] | None = None # price_range_index → 시작값(‰)
|
||||||
|
|
||||||
|
|
||||||
class BaseTableError(RuntimeError):
|
class BaseTableError(RuntimeError):
|
||||||
@ -23,8 +23,8 @@ def _validate(rows: list) -> list[int]:
|
|||||||
|
|
||||||
규약: 46행 · idx 1..46 연속 · upper_bound == 사다리(UPPER_BOUNDS) · 값 0.01~0.20.
|
규약: 46행 · idx 1..46 연속 · upper_bound == 사다리(UPPER_BOUNDS) · 값 0.01~0.20.
|
||||||
"""
|
"""
|
||||||
if not isinstance(rows, list) or len(rows) != BRACKET_COUNT:
|
if not isinstance(rows, list) or len(rows) != PRICE_RANGE_COUNT:
|
||||||
raise BaseTableError(f"정적 테이블 행 수 불일치: {len(rows) if isinstance(rows, list) else type(rows)} != {BRACKET_COUNT}")
|
raise BaseTableError(f"정적 테이블 행 수 불일치: {len(rows) if isinstance(rows, list) else type(rows)} != {PRICE_RANGE_COUNT}")
|
||||||
rates: list[int] = []
|
rates: list[int] = []
|
||||||
for i, row in enumerate(rows):
|
for i, row in enumerate(rows):
|
||||||
idx = row.get("idx")
|
idx = row.get("idx")
|
||||||
@ -52,8 +52,8 @@ def load_base_table() -> None:
|
|||||||
_rates = _validate(rows)
|
_rates = _validate(rows)
|
||||||
|
|
||||||
|
|
||||||
def get_base_rate_permille(bracket_index: int) -> int:
|
def get_base_anchoring_value(price_range_index: int) -> int:
|
||||||
"""구간 인덱스 → 시작 앵커링 값(‰)."""
|
"""구간 인덱스 → 시작 앵커링 값(‰)."""
|
||||||
if _rates is None:
|
if _rates is None:
|
||||||
load_base_table()
|
load_base_table()
|
||||||
return _rates[bracket_index]
|
return _rates[price_range_index]
|
||||||
|
|||||||
@ -6,8 +6,8 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# ── 앵커링 값(정수 천분율 ‰) ──────────────────────────────
|
# ── 앵커링 값(정수 천분율 ‰) ──────────────────────────────
|
||||||
ANCHOR_RATE_MIN = 10 # 하한 1%
|
ANCHORING_VALUE_MIN = 10 # 하한 1%
|
||||||
ANCHOR_RATE_MAX = 200 # 상한 20%
|
ANCHORING_VALUE_MAX = 200 # 상한 20%
|
||||||
# 시작값은 상수가 아니라 정적 테이블(base_table)에서 로드 — 0.01/10 하드코딩 금지
|
# 시작값은 상수가 아니라 정적 테이블(base_table)에서 로드 — 0.01/10 하드코딩 금지
|
||||||
|
|
||||||
# ── 가격구간 (자릿수 계단식 사다리 — 폭 = 구간 상한의 10% = 선행 자릿수 밴드) ──
|
# ── 가격구간 (자릿수 계단식 사다리 — 폭 = 구간 상한의 10% = 선행 자릿수 밴드) ──
|
||||||
@ -25,8 +25,8 @@ def _build_upper_bounds() -> tuple:
|
|||||||
|
|
||||||
|
|
||||||
UPPER_BOUNDS = _build_upper_bounds() # 46개 — 구간 = [이전 upper_bound, upper_bound) 좌폐우개
|
UPPER_BOUNDS = _build_upper_bounds() # 46개 — 구간 = [이전 upper_bound, upper_bound) 좌폐우개
|
||||||
BRACKET_COUNT = len(UPPER_BOUNDS) # 46
|
PRICE_RANGE_COUNT = len(UPPER_BOUNDS) # 46
|
||||||
BRACKET_INDEX_MAX = BRACKET_COUNT - 1 # 45
|
PRICE_RANGE_INDEX_MAX = PRICE_RANGE_COUNT - 1 # 45
|
||||||
|
|
||||||
# 칸을 구성할 수 있는 협력사 유형 코드 — common.enums.SupplierType 의 유통(1)/제조(2)/총판(3).
|
# 칸을 구성할 수 있는 협력사 유형 코드 — common.enums.SupplierType 의 유통(1)/제조(2)/총판(3).
|
||||||
# 이 외(NONE=0/NULL)는 칸 해석 불가 → 정적 테이블 시작값 사용(배치 집계에서도 자동 제외).
|
# 이 외(NONE=0/NULL)는 칸 해석 불가 → 정적 테이블 시작값 사용(배치 집계에서도 자동 제외).
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
원본 reader(schedules/anchoring)는 Redis 캐시를 먼저 보지만, 이식판은 DB 직조회 한 문장만 쓴다
|
원본 reader(schedules/anchoring)는 Redis 캐시를 먼저 보지만, 이식판은 DB 직조회 한 문장만 쓴다
|
||||||
(2026-07-03 단순화 결정 — 조회가 견적 생성 시에만 일어나 캐시가 불필요, Redis 의존 제거).
|
(2026-07-03 단순화 결정 — 조회가 견적 생성 시에만 일어나 캐시가 불필요, Redis 의존 제거).
|
||||||
칸별 최신 조정 rate 는 모듈 소유 뷰 `anchoring.current_rates` 가 제공하고,
|
칸별 최신 조정 rate 는 모듈 소유 뷰 `anchoring.current_values` 가 제공하고,
|
||||||
조정 이력이 없는 칸은 결과에 없으므로 호출측이 정적 테이블 시작값으로 폴백한다.
|
조정 이력이 없는 칸은 결과에 없으므로 호출측이 정적 테이블 시작값으로 폴백한다.
|
||||||
|
|
||||||
견적 생성이 앵커 조회 때문에 실패해서는 안 된다(인수인계.md §1 규칙 6) — anchoring 스키마
|
견적 생성이 앵커 조회 때문에 실패해서는 안 된다(인수인계.md §1 규칙 6) — anchoring 스키마
|
||||||
@ -11,22 +11,22 @@
|
|||||||
from sqlalchemy import column, select, table
|
from sqlalchemy import column, select, table
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from common.anchoring.constants import ANCHOR_RATE_MAX, ANCHOR_RATE_MIN, SAMPLEABLE_SUPPLIER_TYPES
|
from common.anchoring.constants import ANCHORING_VALUE_MAX, ANCHORING_VALUE_MIN, SAMPLEABLE_SUPPLIER_TYPES
|
||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
|
|
||||||
# 모듈 소유 DDL(schedules/anchoring/schema.sql)의 조회용 뷰 — negodata 는 ORM 모델 없이 읽기만 한다.
|
# 모듈 소유 DDL(postgres-init/05-anchoring-schema.sql)의 조회용 뷰 — negodata 는 ORM 모델 없이 읽기만 한다.
|
||||||
_current_rates = table(
|
_current_values = table(
|
||||||
"current_rates",
|
"current_values",
|
||||||
column("company_id"),
|
column("company_id"),
|
||||||
column("price_bracket_index"),
|
column("price_range_index"),
|
||||||
column("anchor_rate_permille"),
|
column("anchoring_value"),
|
||||||
column("supplier_type"),
|
column("supplier_type"),
|
||||||
schema="anchoring",
|
schema="anchoring",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def fetch_current_rates(db: AsyncSession, company_ids: list, supplier_type: int) -> dict:
|
async def fetch_current_values(db: AsyncSession, company_ids: list, supplier_type: int) -> dict:
|
||||||
"""칸별 현재 앵커링 값 일괄 조회. {(company_id, bracket_index): rate‰} 반환.
|
"""칸별 현재 앵커링 값 일괄 조회. {(company_id, price_range_index): rate‰} 반환.
|
||||||
|
|
||||||
supplier_type 은 견적 단위로 하나뿐이라 키에 넣지 않는다.
|
supplier_type 은 견적 단위로 하나뿐이라 키에 넣지 않는다.
|
||||||
조정 이력이 없는 칸은 결과에 없다(호출측 정적 폴백). 조회 실패 시 빈 dict."""
|
조정 이력이 없는 칸은 결과에 없다(호출측 정적 폴백). 조회 실패 시 빈 dict."""
|
||||||
@ -34,22 +34,22 @@ async def fetch_current_rates(db: AsyncSession, company_ids: list, supplier_type
|
|||||||
return {}
|
return {}
|
||||||
try:
|
try:
|
||||||
stmt = select(
|
stmt = select(
|
||||||
_current_rates.c.company_id,
|
_current_values.c.company_id,
|
||||||
_current_rates.c.price_bracket_index,
|
_current_values.c.price_range_index,
|
||||||
_current_rates.c.anchor_rate_permille,
|
_current_values.c.anchoring_value,
|
||||||
).where(
|
).where(
|
||||||
_current_rates.c.company_id.in_(company_ids),
|
_current_values.c.company_id.in_(company_ids),
|
||||||
_current_rates.c.supplier_type == supplier_type,
|
_current_values.c.supplier_type == supplier_type,
|
||||||
)
|
)
|
||||||
rows = (await db.execute(stmt)).all()
|
rows = (await db.execute(stmt)).all()
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
LOG.w(f"[앵커링] current_rates 조회 실패 — 전량 정적 테이블 폴백: {ex}")
|
LOG.w(f"[앵커링] current_values 조회 실패 — 전량 정적 테이블 폴백: {ex}")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
out = {}
|
out = {}
|
||||||
for company_id, bracket_index, rate in rows:
|
for company_id, price_range_index, rate in rows:
|
||||||
if not ANCHOR_RATE_MIN <= rate <= ANCHOR_RATE_MAX: # 범위 밖 값은 오염 방어 — 버리고 정적 폴백
|
if not ANCHORING_VALUE_MIN <= rate <= ANCHORING_VALUE_MAX: # 범위 밖 값은 오염 방어 — 버리고 정적 폴백
|
||||||
LOG.w(f"[앵커링] rate 범위 밖 — 무시(정적 폴백): company={company_id} bracket={bracket_index} rate={rate}")
|
LOG.w(f"[앵커링] rate 범위 밖 — 무시(정적 폴백): company={company_id} bracket={price_range_index} rate={rate}")
|
||||||
continue
|
continue
|
||||||
out[(company_id, bracket_index)] = rate
|
out[(company_id, price_range_index)] = rate
|
||||||
return out
|
return out
|
||||||
|
|||||||
@ -5,17 +5,17 @@
|
|||||||
"""
|
"""
|
||||||
from bisect import bisect_right
|
from bisect import bisect_right
|
||||||
|
|
||||||
from common.anchoring.constants import BRACKET_INDEX_MAX, UPPER_BOUNDS
|
from common.anchoring.constants import PRICE_RANGE_INDEX_MAX, UPPER_BOUNDS
|
||||||
|
|
||||||
|
|
||||||
def calc_bracket_index(target_price: int) -> int:
|
def calc_price_range_index(target_price: int) -> int:
|
||||||
"""목표가 → 가격구간 인덱스(0-기반). 자릿수 계단식 사다리.
|
"""목표가 → 가격구간 인덱스(0-기반). 자릿수 계단식 사다리.
|
||||||
|
|
||||||
좌폐우개 [이전 ub, ub): 가격이 upper_bound 와 정확히 같으면 다음 칸.
|
좌폐우개 [이전 ub, ub): 가격이 upper_bound 와 정확히 같으면 다음 칸.
|
||||||
1억 이상은 마지막 인덱스로 클램프. 정적 테이블 idx = 반환값 + 1"""
|
1억 이상은 마지막 인덱스로 클램프. 정적 테이블 idx = 반환값 + 1"""
|
||||||
return min(bisect_right(UPPER_BOUNDS, target_price), BRACKET_INDEX_MAX)
|
return min(bisect_right(UPPER_BOUNDS, target_price), PRICE_RANGE_INDEX_MAX)
|
||||||
|
|
||||||
|
|
||||||
def calc_anchor_price(target_price: int, rate_permille: int) -> int:
|
def calc_anchoring_price(target_price: int, anchoring_value: int) -> int:
|
||||||
"""앵커링가 = 목표가 × (1 − A), 1원 단위 내림. (정수 연산만 — float 곱셈 재도입 금지)"""
|
"""앵커링가 = 목표가 × (1 − A), 1원 단위 내림. (정수 연산만 — float 곱셈 재도입 금지)"""
|
||||||
return target_price * (1000 - rate_permille) // 1000
|
return target_price * (1000 - anchoring_value) // 1000
|
||||||
|
|||||||
@ -246,8 +246,8 @@ class sessions(MainTableMixin, MAIN_BASE):
|
|||||||
qt_round = Column(Integer, nullable=False) # 견적 라운드 스냅샷
|
qt_round = Column(Integer, nullable=False) # 견적 라운드 스냅샷
|
||||||
qt_type = Column(SmallInteger, nullable=False) # QuotationType 스냅샷
|
qt_type = Column(SmallInteger, nullable=False) # QuotationType 스냅샷
|
||||||
target_price = Column(BigInteger, nullable=False) # 목표가(원)
|
target_price = Column(BigInteger, nullable=False) # 목표가(원)
|
||||||
target_anchoring_price = Column(BigInteger, nullable=True) # 앵커링가(원) — 생성 시 박제, 이후 수정 금지(앵커링 배치 판정 기준)
|
anchoring_price = Column(BigInteger, nullable=True) # 앵커링가(원) — 생성 시 박제, 이후 수정 금지(앵커링 배치 판정 기준)
|
||||||
anchor_rate_permille = Column(SmallInteger, nullable=True) # 제안 당시 앵커링 값(천분율‰) 박제 — 위와 동일 규칙. 나머지 앵커링 컬럼(last_offered_price 등)은 backend/배치 소유라 매핑 안 함
|
anchoring_value = Column(SmallInteger, nullable=True) # 제안 당시 앵커링 값(정수 ‰) 박제 — 위와 동일 규칙. 주의: quotation_settings.anchoring_value(구 float 비율)와 무관. 나머지 앵커링 컬럼(last_offer_price 등)은 backend/배치 소유라 매핑 안 함
|
||||||
status = Column(SmallInteger, nullable=False) # SessionStatus 코드
|
status = Column(SmallInteger, nullable=False) # SessionStatus 코드
|
||||||
bid_price = Column(BigInteger, nullable=True) # 입찰가(원)
|
bid_price = Column(BigInteger, nullable=True) # 입찰가(원)
|
||||||
bid_at = Column(DateTime(timezone=True), nullable=True) # 입찰 시각
|
bid_at = Column(DateTime(timezone=True), nullable=True) # 입찰 시각
|
||||||
|
|||||||
@ -611,13 +611,13 @@ class QuotationCRUD(IQuotationCRUD):
|
|||||||
return ErrorType.DB_RUN_FAILED, 0
|
return ErrorType.DB_RUN_FAILED, 0
|
||||||
|
|
||||||
async def list_sessions_status(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, list]:
|
async def list_sessions_status(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, list]:
|
||||||
"""[마감 판정] 견적의 모든 세션 → (status, supplier_id, bid_price, name, target_price, target_anchoring_price). 삭제 제외.
|
"""[마감 판정] 견적의 모든 세션 → (status, supplier_id, bid_price, name, target_price, anchoring_price). 삭제 제외.
|
||||||
공급사가 지워졌어도 세션 집계엔 포함되도록 outerjoin(이때 name 은 None).
|
공급사가 지워졌어도 세션 집계엔 포함되도록 outerjoin(이때 name 은 None).
|
||||||
target/anchoring 은 마감 가격게이트 입력(견적당 상품 1개라 세션 공통값)."""
|
target/anchoring 은 마감 가격게이트 입력(견적당 상품 1개라 세션 공통값)."""
|
||||||
try:
|
try:
|
||||||
query = (
|
query = (
|
||||||
select(sessions.status, sessions.supplier_id, sessions.bid_price, suppliers.name,
|
select(sessions.status, sessions.supplier_id, sessions.bid_price, suppliers.name,
|
||||||
sessions.target_price, sessions.target_anchoring_price)
|
sessions.target_price, sessions.anchoring_price)
|
||||||
.outerjoin(suppliers, suppliers.supplier_id == sessions.supplier_id)
|
.outerjoin(suppliers, suppliers.supplier_id == sessions.supplier_id)
|
||||||
.where(sessions.quotation_id == qt_id, sessions.deleted == False) # noqa: E712
|
.where(sessions.quotation_id == qt_id, sessions.deleted == False) # noqa: E712
|
||||||
)
|
)
|
||||||
|
|||||||
@ -100,7 +100,7 @@ class SessionData(WebPacketProtocol):
|
|||||||
qt_round: int
|
qt_round: int
|
||||||
qt_type: QuotationType
|
qt_type: QuotationType
|
||||||
target_price: int
|
target_price: int
|
||||||
target_anchoring_price: Optional[int] = None # 앵커링가(원). 목표가×(1−앵커링율)
|
anchoring_price: Optional[int] = None # 앵커링가(원). 목표가×(1000−anchoring_value)//1000
|
||||||
status: SessionStatus
|
status: SessionStatus
|
||||||
bid_price: Optional[int] = None
|
bid_price: Optional[int] = None
|
||||||
bid_at: Optional[datetime] = None
|
bid_at: Optional[datetime] = None
|
||||||
@ -205,7 +205,8 @@ class Res_TargetBreakdown(Res_WebPacketProtocol):
|
|||||||
selling: Optional[int] = None
|
selling: Optional[int] = None
|
||||||
fee: float = 0.0
|
fee: float = 0.0
|
||||||
margin: float = 0.0
|
margin: float = 0.0
|
||||||
|
anchoring_value: float = 0.0 # 앵커링율(비율). 세션 앵커링값(‰)을 /1000 환산 — main 프론트 표시용
|
||||||
candidates: list[TargetCandidate] = []
|
candidates: list[TargetCandidate] = []
|
||||||
chosen_basis: Optional[str] = None
|
chosen_basis: Optional[str] = None
|
||||||
target_price: int = 0
|
target_price: int = 0
|
||||||
target_anchoring_price: Optional[int] = None
|
anchoring_price: Optional[int] = None
|
||||||
|
|||||||
@ -114,7 +114,7 @@ VALUES
|
|||||||
INSERT INTO negotiation.sessions
|
INSERT INTO negotiation.sessions
|
||||||
(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type,
|
(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type,
|
||||||
target_price, status, bid_price, bid_at, end_time,
|
target_price, status, bid_price, bid_at, end_time,
|
||||||
reject_reason, reject_price, reject_delivery_type, target_anchoring_price, email_sent_at,
|
reject_reason, reject_price, reject_delivery_type, anchoring_price, email_sent_at,
|
||||||
created_at, updated_at, deleted)
|
created_at, updated_at, deleted)
|
||||||
VALUES
|
VALUES
|
||||||
-- ① 낙찰(절감): S1 372,000(낙찰) / S2 389,000 / S3 395,000
|
-- ① 낙찰(절감): S1 372,000(낙찰) / S2 389,000 / S3 395,000
|
||||||
|
|||||||
@ -7,10 +7,10 @@ from fastapi import Depends
|
|||||||
|
|
||||||
from common.anchoring import (
|
from common.anchoring import (
|
||||||
SAMPLEABLE_SUPPLIER_TYPES,
|
SAMPLEABLE_SUPPLIER_TYPES,
|
||||||
calc_anchor_price,
|
calc_anchoring_price,
|
||||||
calc_bracket_index,
|
calc_price_range_index,
|
||||||
fetch_current_rates,
|
fetch_current_values,
|
||||||
get_base_rate_permille,
|
get_base_anchoring_value,
|
||||||
)
|
)
|
||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
from common.database.model.models import quotations, sessions, chats, versions, version_nego_cards, version_wild_cards
|
from common.database.model.models import quotations, sessions, chats, versions, version_nego_cards, version_wild_cards
|
||||||
@ -186,7 +186,8 @@ class QuotationService:
|
|||||||
res.candidates = [TargetCandidate(basis=b, label=self._CANDIDATE_LABELS.get(b, b), value=int(v)) for b, v in cands]
|
res.candidates = [TargetCandidate(basis=b, label=self._CANDIDATE_LABELS.get(b, b), value=int(v)) for b, v in cands]
|
||||||
res.chosen_basis = None if is_inherited else chosen_basis
|
res.chosen_basis = None if is_inherited else chosen_basis
|
||||||
res.target_price = sess.target_price
|
res.target_price = sess.target_price
|
||||||
res.target_anchoring_price = sess.target_anchoring_price
|
res.anchoring_price = sess.anchoring_price
|
||||||
|
res.anchoring_value = (sess.anchoring_value or 0) / 1000 # 세션 ‰ → 비율(main 프론트 '목표가×(1−값)' 표시용)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
async def list_quotations(self, company_id, owner, search, status, type_, start_from, start_to, pg: PageParams) -> Res_QuotationList:
|
async def list_quotations(self, company_id, owner, search, status, type_, start_from, start_to, pg: PageParams) -> Res_QuotationList:
|
||||||
@ -415,8 +416,8 @@ class QuotationService:
|
|||||||
rates = rates if _err == ErrorType.SUCCESS else {}
|
rates = rates if _err == ErrorType.SUCCESS else {}
|
||||||
fee = self.INTERNET_AVERAGE_FEE # 인터넷가 차감 수수료율(상수)
|
fee = self.INTERNET_AVERAGE_FEE # 인터넷가 차감 수수료율(상수)
|
||||||
margin = rates.get("margin") or 0.0 # 판매가 차감 목표마진율
|
margin = rates.get("margin") or 0.0 # 판매가 차감 목표마진율
|
||||||
# 앵커링가는 quotation_settings.anchoring_value 를 더 이상 쓰지 않는다(앵커링 v1.2) —
|
# 앵커링가는 quotation_settings.anchoring_value(구 float 비율)를 더 이상 쓰지 않는다(앵커링 v1.2) —
|
||||||
# 칸(회사×협력사유형×가격구간)별 조정 rate 로 계산한다. 아래 세션 생성부 ②.
|
# 칸(회사×협력사유형×가격구간)별 조정 anchoring_value(정수 ‰)로 계산한다. 아래 세션 생성부 ②.
|
||||||
|
|
||||||
# 선택 협상카드가 있으면 새 버전을 만들어 카드들을 묶고, quotation.version_id 로 연결한다.
|
# 선택 협상카드가 있으면 새 버전을 만들어 카드들을 묶고, quotation.version_id 로 연결한다.
|
||||||
# (quotation↔card 는 version → version_nego_cards/version_wild_cards 로 연결.)
|
# (quotation↔card 는 version → version_nego_cards/version_wild_cards 로 연결.)
|
||||||
@ -489,32 +490,32 @@ class QuotationService:
|
|||||||
res.result.SetResult(ErrorType.QUOTATION_TARGET_PRICE_UNAVAILABLE)
|
res.result.SetResult(ErrorType.QUOTATION_TARGET_PRICE_UNAVAILABLE)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
# ② 앵커가 산출 — 칸(items.company_id × quotations.supplier_type × 목표가 구간) rate 조회 후
|
# ② 앵커가 산출 — 칸(items.company_id × quotations.supplier_type × 목표가 구간) anchoring_value 조회 후
|
||||||
# 정수 연산으로 박제(앵커링 v1.2, 인수인계.md §1.3). 유형 미지정/조정 이력 없음/조회 실패는
|
# 정수 연산으로 박제(앵커링 v1.2, 인수인계.md §1.3). 유형 미지정/조정 이력 없음/조회 실패는
|
||||||
# 정적 테이블 시작값 폴백 — rate 조회 때문에 견적 생성이 실패하지 않는다(규칙 6).
|
# 정적 테이블 시작값 폴백 — 값 조회 때문에 견적 생성이 실패하지 않는다(규칙 6).
|
||||||
_err, item_companies = await DB_SESSION_MNG.execute_lambda(
|
_err, item_companies = await DB_SESSION_MNG.execute_lambda(
|
||||||
quotations.DBType(),
|
quotations.DBType(),
|
||||||
DBWRType.DB_READ.value,
|
DBWRType.DB_READ.value,
|
||||||
lambda s: self.quotation_crud.get_item_companies(s, item_ids),
|
lambda s: self.quotation_crud.get_item_companies(s, item_ids),
|
||||||
)
|
)
|
||||||
item_companies = item_companies if _err == ErrorType.SUCCESS else {}
|
item_companies = item_companies if _err == ErrorType.SUCCESS else {}
|
||||||
rate_map = {}
|
value_map = {}
|
||||||
if supplier_type in SAMPLEABLE_SUPPLIER_TYPES and item_companies:
|
if supplier_type in SAMPLEABLE_SUPPLIER_TYPES and item_companies:
|
||||||
rate_map = await DB_SESSION_MNG.execute_lambda(
|
value_map = await DB_SESSION_MNG.execute_lambda(
|
||||||
quotations.DBType(),
|
quotations.DBType(),
|
||||||
DBWRType.DB_READ.value,
|
DBWRType.DB_READ.value,
|
||||||
lambda s: fetch_current_rates(s, list(set(item_companies.values())), supplier_type),
|
lambda s: fetch_current_values(s, list(set(item_companies.values())), supplier_type),
|
||||||
)
|
)
|
||||||
|
|
||||||
session_objs = []
|
session_objs = []
|
||||||
for iid in item_ids:
|
for iid in item_ids:
|
||||||
tp = target_prices[iid]
|
tp = target_prices[iid]
|
||||||
bracket = calc_bracket_index(tp)
|
price_range = calc_price_range_index(tp)
|
||||||
company = item_companies.get(iid)
|
company = item_companies.get(iid)
|
||||||
rate = rate_map.get((company, bracket)) if company is not None else None
|
value = value_map.get((company, price_range)) if company is not None else None
|
||||||
if rate is None:
|
if value is None:
|
||||||
rate = get_base_rate_permille(bracket)
|
value = get_base_anchoring_value(price_range)
|
||||||
ap = calc_anchor_price(tp, rate) # 목표가×(1000−rate)//1000 — float 곱셈 금지(1원 내림 정확성)
|
ap = calc_anchoring_price(tp, value) # 목표가×(1000−value)//1000 — float 곱셈 금지(1원 내림 정확성)
|
||||||
for sid in supplier_ids:
|
for sid in supplier_ids:
|
||||||
session_objs.append(
|
session_objs.append(
|
||||||
sessions(
|
sessions(
|
||||||
@ -526,8 +527,8 @@ class QuotationService:
|
|||||||
qt_round=quotation.round,
|
qt_round=quotation.round,
|
||||||
qt_type=quotation.type,
|
qt_type=quotation.type,
|
||||||
target_price=tp,
|
target_price=tp,
|
||||||
target_anchoring_price=ap, # 박제 — 이후 수정 금지(협상 판정·앵커링 학습 기준값)
|
anchoring_price=ap, # 박제 — 이후 수정 금지(협상 판정·앵커링 학습 기준값)
|
||||||
anchor_rate_permille=rate,
|
anchoring_value=value,
|
||||||
status=SessionStatus.CREATED.value,
|
status=SessionStatus.CREATED.value,
|
||||||
end_time=quotation.end_time,
|
end_time=quotation.end_time,
|
||||||
)
|
)
|
||||||
@ -645,7 +646,7 @@ class QuotationService:
|
|||||||
mid_action = original.mid_action or PriceGateAction.AWARD.value
|
mid_action = original.mid_action or PriceGateAction.AWARD.value
|
||||||
over_action = original.over_action or PriceGateAction.AWARD.value
|
over_action = original.over_action or PriceGateAction.AWARD.value
|
||||||
target = next((r.target_price for r in rows if r.target_price is not None), None)
|
target = next((r.target_price for r in rows if r.target_price is not None), None)
|
||||||
anchor = next((r.target_anchoring_price for r in rows if r.target_anchoring_price is not None), None)
|
anchor = next((r.anchoring_price for r in rows if r.anchoring_price is not None), None)
|
||||||
|
|
||||||
# 1) 단독 최저가가 낙찰 기준 통과 → 낙찰. 미달 → 개찰(가격).
|
# 1) 단독 최저가가 낙찰 기준 통과 → 낙찰. 미달 → 개찰(가격).
|
||||||
if winner is not None:
|
if winner is not None:
|
||||||
@ -797,7 +798,7 @@ class QuotationService:
|
|||||||
qt_round=r.qt_round,
|
qt_round=r.qt_round,
|
||||||
qt_type=r.qt_type,
|
qt_type=r.qt_type,
|
||||||
target_price=r.target_price,
|
target_price=r.target_price,
|
||||||
target_anchoring_price=r.target_anchoring_price,
|
anchoring_price=r.anchoring_price,
|
||||||
status=r.status,
|
status=r.status,
|
||||||
bid_price=r.bid_price,
|
bid_price=r.bid_price,
|
||||||
bid_at=r.bid_at,
|
bid_at=r.bid_at,
|
||||||
|
|||||||
@ -89,7 +89,7 @@ async def test_anchor_only_opens_within_target(clean):
|
|||||||
status=QuotationStatus.IN_PROGRESS.value,
|
status=QuotationStatus.IN_PROGRESS.value,
|
||||||
mid_action=PriceGateAction.OPEN.value, over_action=PriceGateAction.OPEN.value)
|
mid_action=PriceGateAction.OPEN.value, over_action=PriceGateAction.OPEN.value)
|
||||||
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=120,
|
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=120,
|
||||||
target_price=200, target_anchoring_price=80)
|
target_price=200, anchoring_price=80)
|
||||||
|
|
||||||
outcome = await QuotationService(QuotationCRUD()).close_and_decide(qt)
|
outcome = await QuotationService(QuotationCRUD()).close_and_decide(qt)
|
||||||
|
|
||||||
@ -161,14 +161,14 @@ async def _seed_quotation(
|
|||||||
|
|
||||||
|
|
||||||
async def _add_session(engine, qt_id, *, status, bid_price=None, supplier_id=None,
|
async def _add_session(engine, qt_id, *, status, bid_price=None, supplier_id=None,
|
||||||
target_price=0, target_anchoring_price=None):
|
target_price=0, anchoring_price=None):
|
||||||
"""세션 1건 시드(공급사 협상 1건). target_price/앵커링가로 가격게이트 입력을 만든다."""
|
"""세션 1건 시드(공급사 협상 1건). target_price/앵커링가로 가격게이트 입력을 만든다."""
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
text(
|
text(
|
||||||
"INSERT INTO sessions "
|
"INSERT INTO sessions "
|
||||||
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, "
|
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, "
|
||||||
" target_price, target_anchoring_price, status, bid_price, end_time) VALUES "
|
" target_price, anchoring_price, status, bid_price, end_time) VALUES "
|
||||||
"(:session_id, :quotation_id, :item_id, :supplier_id, :qt_number, :qt_round, :qt_type, "
|
"(:session_id, :quotation_id, :item_id, :supplier_id, :qt_number, :qt_round, :qt_type, "
|
||||||
" :target_price, :anchor, :status, :bid_price, :end_time)"
|
" :target_price, :anchor, :status, :bid_price, :end_time)"
|
||||||
),
|
),
|
||||||
@ -176,7 +176,7 @@ async def _add_session(engine, qt_id, *, status, bid_price=None, supplier_id=Non
|
|||||||
"session_id": uuid.uuid4(), "quotation_id": qt_id, "item_id": uuid.uuid4(),
|
"session_id": uuid.uuid4(), "quotation_id": qt_id, "item_id": uuid.uuid4(),
|
||||||
"supplier_id": supplier_id or uuid.uuid4(), "qt_number": "Q", "qt_round": 1,
|
"supplier_id": supplier_id or uuid.uuid4(), "qt_number": "Q", "qt_round": 1,
|
||||||
"qt_type": QuotationType.REQUOTE.value, "status": status,
|
"qt_type": QuotationType.REQUOTE.value, "status": status,
|
||||||
"target_price": target_price, "anchor": target_anchoring_price,
|
"target_price": target_price, "anchor": anchoring_price,
|
||||||
"bid_price": bid_price, "end_time": PAST,
|
"bid_price": bid_price, "end_time": PAST,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,29 +1,29 @@
|
|||||||
"""앵커링 v1.2 — 견적 생성 시 칸(회사×협력사유형×가격구간) rate 로 앵커가를 박제하는지 검증.
|
"""앵커링 v1.2 — 견적 생성 시 칸(회사×협력사유형×가격구간) anchoring_value 로 앵커가를 박제하는지 검증.
|
||||||
|
|
||||||
이식 명세: schedules/anchoring/docs/인수인계.md §1.
|
이식 명세: schedules/anchoring/docs/인수인계.md §1.
|
||||||
- 앵커가 = 목표가 × (1000 − rate) // 1000 (정수 연산), anchor_rate_permille 동시 박제
|
- 앵커가 = 목표가 × (1000 − anchoring_value) // 1000 (정수 연산), anchoring_value 동시 박제
|
||||||
- 조정 이력 없음 / 유형 미지정 / anchoring 스키마 미적용 → 정적 테이블 시작값(10‰) 폴백,
|
- 조정 이력 없음 / 유형 미지정 / anchoring 스키마 미적용 → 정적 테이블 시작값(10‰) 폴백,
|
||||||
견적 생성은 실패하지 않는다(규칙 6)
|
견적 생성은 실패하지 않는다(규칙 6)
|
||||||
- 재생성 라운드는 목표가만 상속하고 앵커는 생성 시점 rate 로 재계산(규칙 1 — 상속 폐지)
|
- 재생성 라운드는 목표가만 상속하고 앵커는 생성 시점 anchoring_value 로 재계산(규칙 1 — 상속 폐지)
|
||||||
"""
|
"""
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from common.anchoring import calc_bracket_index
|
from common.anchoring import calc_price_range_index
|
||||||
from common.enums import QuotationType
|
from common.enums import QuotationType
|
||||||
from crud.quotation_crud import QuotationCRUD
|
from crud.quotation_crud import QuotationCRUD
|
||||||
from router.v1.quotation.protocol import Req_CreateQuotation
|
from router.v1.quotation.protocol import Req_CreateQuotation
|
||||||
from services.quotation_service import QuotationService
|
from services.quotation_service import QuotationService
|
||||||
|
|
||||||
FUTURE = datetime(2999, 1, 1) # 마감시각 미래 — 생성 직후 크론에 안 잡히게
|
FUTURE = datetime(2999, 1, 1) # 마감시각 미래 — 생성 직후 크론에 안 잡히게
|
||||||
BASE_RATE = 10 # 정적 테이블 시작값(‰) — anchoring_base.json 전 구간 0.01
|
BASE_VALUE = 10 # 정적 테이블 시작값(‰) — anchoring_base.json 전 구간 0.01
|
||||||
|
|
||||||
|
|
||||||
async def test_create_without_anchoring_schema_falls_back_to_base_rate(db_engine, company_id):
|
async def test_create_without_anchoring_schema_falls_back_to_base_value(db_engine, company_id):
|
||||||
"""검증: anchoring 스키마가 아예 없는 DB 에서 supplier_type=1(유통) 견적 생성.
|
"""검증: anchoring 스키마가 아예 없는 DB 에서 supplier_type=1(유통) 견적 생성.
|
||||||
기대결과: 조회 실패에도 생성 성공 + 앵커가=목표가×990‰(시작값), rate=10 박제."""
|
기대결과: 조회 실패에도 생성 성공 + 앵커가=목표가×990‰(시작값), anchoring_value=10 박제."""
|
||||||
await _drop_anchoring(db_engine)
|
await _drop_anchoring(db_engine)
|
||||||
item = await _seed_item(db_engine, company_id, internet_lowest=100_000)
|
item = await _seed_item(db_engine, company_id, internet_lowest=100_000)
|
||||||
|
|
||||||
@ -32,10 +32,10 @@ async def test_create_without_anchoring_schema_falls_back_to_base_rate(db_engine
|
|||||||
assert res.result.success is True
|
assert res.result.success is True
|
||||||
tp = int(100_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE)) # 92,200
|
tp = int(100_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE)) # 92,200
|
||||||
rows = await _session_anchor_rows(db_engine, res.qt_id)
|
rows = await _session_anchor_rows(db_engine, res.qt_id)
|
||||||
assert rows == {item: (tp, tp * (1000 - BASE_RATE) // 1000, BASE_RATE)}
|
assert rows == {item: (tp, tp * (1000 - BASE_VALUE) // 1000, BASE_VALUE)}
|
||||||
|
|
||||||
|
|
||||||
async def test_create_uses_latest_adjusted_rate_per_cell(db_engine, company_id):
|
async def test_create_uses_latest_adjusted_value_per_cell(db_engine, company_id):
|
||||||
"""검증: 한 상품의 칸에만 조정 이력(50‰)을 넣고 상품 2개(다른 가격구간)로 견적 생성.
|
"""검증: 한 상품의 칸에만 조정 이력(50‰)을 넣고 상품 2개(다른 가격구간)로 견적 생성.
|
||||||
기대결과: 이력 칸 상품은 50‰, 무이력 칸 상품은 시작값 10‰ 로 각각 박제(칸 단위 조회)."""
|
기대결과: 이력 칸 상품은 50‰, 무이력 칸 상품은 시작값 10‰ 로 각각 박제(칸 단위 조회)."""
|
||||||
await _reset_anchoring(db_engine)
|
await _reset_anchoring(db_engine)
|
||||||
@ -43,29 +43,29 @@ async def test_create_uses_latest_adjusted_rate_per_cell(db_engine, company_id):
|
|||||||
item_miss = await _seed_item(db_engine, company_id, internet_lowest=5_000) # tp 4,610 — 다른 구간
|
item_miss = await _seed_item(db_engine, company_id, internet_lowest=5_000) # tp 4,610 — 다른 구간
|
||||||
tp_hit = int(100_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE))
|
tp_hit = int(100_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE))
|
||||||
tp_miss = int(5_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE))
|
tp_miss = int(5_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE))
|
||||||
await _seed_adjustment(db_engine, company_id, supplier_type=1, bracket=calc_bracket_index(tp_hit), rate_after=50)
|
await _seed_adjustment(db_engine, company_id, supplier_type=1, price_range=calc_price_range_index(tp_hit), value_after=50)
|
||||||
|
|
||||||
res = await _create(item_ids=[item_hit, item_miss], supplier_type=1)
|
res = await _create(item_ids=[item_hit, item_miss], supplier_type=1)
|
||||||
|
|
||||||
assert res.result.success is True
|
assert res.result.success is True
|
||||||
rows = await _session_anchor_rows(db_engine, res.qt_id)
|
rows = await _session_anchor_rows(db_engine, res.qt_id)
|
||||||
assert rows[item_hit] == (tp_hit, tp_hit * 950 // 1000, 50)
|
assert rows[item_hit] == (tp_hit, tp_hit * 950 // 1000, 50)
|
||||||
assert rows[item_miss] == (tp_miss, tp_miss * 990 // 1000, BASE_RATE)
|
assert rows[item_miss] == (tp_miss, tp_miss * 990 // 1000, BASE_VALUE)
|
||||||
|
|
||||||
|
|
||||||
async def test_supplier_type_unset_uses_base_rate(db_engine, company_id):
|
async def test_supplier_type_unset_uses_base_value(db_engine, company_id):
|
||||||
"""검증: supplier_type 미지정(None) 견적 생성 — 칸(회사×유형×구간) 구성 불가.
|
"""검증: supplier_type 미지정(None) 견적 생성 — 칸(회사×유형×구간) 구성 불가.
|
||||||
기대결과: 같은 회사·구간에 조정 이력이 있어도 쓰지 않고 시작값 10‰ 박제."""
|
기대결과: 같은 회사·구간에 조정 이력이 있어도 쓰지 않고 시작값 10‰ 박제."""
|
||||||
await _reset_anchoring(db_engine)
|
await _reset_anchoring(db_engine)
|
||||||
item = await _seed_item(db_engine, company_id, internet_lowest=100_000)
|
item = await _seed_item(db_engine, company_id, internet_lowest=100_000)
|
||||||
tp = int(100_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE))
|
tp = int(100_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE))
|
||||||
await _seed_adjustment(db_engine, company_id, supplier_type=1, bracket=calc_bracket_index(tp), rate_after=50)
|
await _seed_adjustment(db_engine, company_id, supplier_type=1, price_range=calc_price_range_index(tp), value_after=50)
|
||||||
|
|
||||||
res = await _create(item_ids=[item], supplier_type=None)
|
res = await _create(item_ids=[item], supplier_type=None)
|
||||||
|
|
||||||
assert res.result.success is True
|
assert res.result.success is True
|
||||||
rows = await _session_anchor_rows(db_engine, res.qt_id)
|
rows = await _session_anchor_rows(db_engine, res.qt_id)
|
||||||
assert rows == {item: (tp, tp * 990 // 1000, BASE_RATE)}
|
assert rows == {item: (tp, tp * 990 // 1000, BASE_VALUE)}
|
||||||
|
|
||||||
|
|
||||||
async def test_regenerate_inherits_target_but_recomputes_anchor(db_engine, company_id):
|
async def test_regenerate_inherits_target_but_recomputes_anchor(db_engine, company_id):
|
||||||
@ -79,28 +79,28 @@ async def test_regenerate_inherits_target_but_recomputes_anchor(db_engine, compa
|
|||||||
assert res1.result.success is True
|
assert res1.result.success is True
|
||||||
tp = int(100_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE))
|
tp = int(100_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE))
|
||||||
rows1 = await _session_anchor_rows(db_engine, res1.qt_id)
|
rows1 = await _session_anchor_rows(db_engine, res1.qt_id)
|
||||||
assert rows1 == {item: (tp, tp * 990 // 1000, BASE_RATE)} # 1라운드는 시작값
|
assert rows1 == {item: (tp, tp * 990 // 1000, BASE_VALUE)} # 1라운드는 시작값
|
||||||
|
|
||||||
await _seed_adjustment(db_engine, company_id, supplier_type=1, bracket=calc_bracket_index(tp), rate_after=50)
|
await _seed_adjustment(db_engine, company_id, supplier_type=1, price_range=calc_price_range_index(tp), value_after=50)
|
||||||
res2 = await _service().regenerate_next_round(res1.qt_id, [supplier])
|
res2 = await _service().regenerate_next_round(res1.qt_id, [supplier])
|
||||||
|
|
||||||
assert res2.result.success is True
|
assert res2.result.success is True
|
||||||
rows2 = await _session_anchor_rows(db_engine, res2.qt_id)
|
rows2 = await _session_anchor_rows(db_engine, res2.qt_id)
|
||||||
assert rows2 == {item: (tp, tp * 950 // 1000, 50)} # 목표가 상속 + 앵커만 현재 rate
|
assert rows2 == {item: (tp, tp * 950 // 1000, 50)} # 목표가 상속 + 앵커만 현재 anchoring_value
|
||||||
|
|
||||||
|
|
||||||
def test_bracket_index_golden_vectors():
|
def test_price_range_index_golden_vectors():
|
||||||
"""검증: 이식된 calc_bracket_index 경계 골든 벡터(자릿수 사다리, 좌폐우개).
|
"""검증: 이식된 calc_price_range_index 경계 골든 벡터(자릿수 사다리, 좌폐우개).
|
||||||
배치의 박제 정합 감시는 rate↔앵커가 자기일관만 보므로 브래킷 이식 오류를 못 잡는다 —
|
배치의 박제 정합 감시는 값↔앵커가 자기일관만 보므로 브래킷 이식 오류를 못 잡는다 —
|
||||||
이 벡터가 원본(schedules/anchoring)과 어긋나면 이식 오류다(값 변경 금지)."""
|
이 벡터가 원본(schedules/anchoring)과 어긋나면 이식 오류다(값 변경 금지)."""
|
||||||
assert calc_bracket_index(0) == 0 # 최하단 통일 칸 [0, 1,000)
|
assert calc_price_range_index(0) == 0 # 최하단 통일 칸 [0, 1,000)
|
||||||
assert calc_bracket_index(999) == 0
|
assert calc_price_range_index(999) == 0
|
||||||
assert calc_bracket_index(1_000) == 1 # 경계 = 다음 칸(좌폐우개)
|
assert calc_price_range_index(1_000) == 1 # 경계 = 다음 칸(좌폐우개)
|
||||||
assert calc_bracket_index(9_999) == 9
|
assert calc_price_range_index(9_999) == 9
|
||||||
assert calc_bracket_index(10_000) == 10 # 자릿수 전환 경계
|
assert calc_price_range_index(10_000) == 10 # 자릿수 전환 경계
|
||||||
assert calc_bracket_index(99_999_999) == 45
|
assert calc_price_range_index(99_999_999) == 45
|
||||||
assert calc_bracket_index(100_000_000) == 45 # 1억 이상은 마지막 칸 클램프
|
assert calc_price_range_index(100_000_000) == 45 # 1억 이상은 마지막 칸 클램프
|
||||||
assert calc_bracket_index(10**12) == 45
|
assert calc_price_range_index(10**12) == 45
|
||||||
|
|
||||||
|
|
||||||
# ===== 헬퍼 =====
|
# ===== 헬퍼 =====
|
||||||
@ -140,16 +140,16 @@ async def _seed_item(engine, company_id, *, internet_lowest):
|
|||||||
|
|
||||||
|
|
||||||
async def _session_anchor_rows(engine, qt_id):
|
async def _session_anchor_rows(engine, qt_id):
|
||||||
"""생성된 견적의 item_id -> (target_price, target_anchoring_price, anchor_rate_permille)."""
|
"""생성된 견적의 item_id -> (target_price, anchoring_price, anchoring_value)."""
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
rows = (await conn.execute(
|
rows = (await conn.execute(
|
||||||
text("SELECT item_id, target_price, target_anchoring_price, anchor_rate_permille "
|
text("SELECT item_id, target_price, anchoring_price, anchoring_value "
|
||||||
"FROM sessions WHERE quotation_id = :qt"),
|
"FROM sessions WHERE quotation_id = :qt"),
|
||||||
{"qt": qt_id},
|
{"qt": qt_id},
|
||||||
)).all()
|
)).all()
|
||||||
out = {}
|
out = {}
|
||||||
for item_id, tp, ap, rate in rows:
|
for item_id, tp, ap, value in rows:
|
||||||
assert out.setdefault(item_id, (tp, ap, rate)) == (tp, ap, rate) # 같은 상품 세션끼리 동일 박제
|
assert out.setdefault(item_id, (tp, ap, value)) == (tp, ap, value) # 같은 상품 세션끼리 동일 박제
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@ -158,29 +158,29 @@ async def _drop_anchoring(engine):
|
|||||||
await conn.execute(text("DROP SCHEMA IF EXISTS anchoring CASCADE"))
|
await conn.execute(text("DROP SCHEMA IF EXISTS anchoring CASCADE"))
|
||||||
|
|
||||||
|
|
||||||
# 모듈 소유 DDL(schedules/anchoring/schema.sql)에서 조회 경로에 필요한 부분 발췌.
|
# 모듈 소유 DDL(postgres-init/05-anchoring-schema.sql)에서 조회 경로에 필요한 부분 발췌.
|
||||||
# negodata 는 이 스키마를 만들지 않는다(모듈이 소유) — 테스트 재현용으로만 여기 둔다.
|
# negodata 는 이 스키마를 만들지 않는다(모듈이 소유) — 테스트 재현용으로만 여기 둔다.
|
||||||
_ANCHORING_DDL = (
|
_ANCHORING_DDL = (
|
||||||
"CREATE SCHEMA IF NOT EXISTS anchoring",
|
"CREATE SCHEMA IF NOT EXISTS anchoring",
|
||||||
"""CREATE TABLE IF NOT EXISTS anchoring.rate_adjustments (
|
"""CREATE TABLE IF NOT EXISTS anchoring.adjustments (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
adjustment_id BIGSERIAL PRIMARY KEY,
|
||||||
company_id uuid NOT NULL,
|
company_id uuid NOT NULL,
|
||||||
supplier_type SMALLINT NOT NULL,
|
supplier_type SMALLINT NOT NULL,
|
||||||
price_bracket_index INTEGER NOT NULL,
|
price_range_index INTEGER NOT NULL,
|
||||||
nego_count INTEGER NOT NULL,
|
sample_count INTEGER NOT NULL,
|
||||||
success_count INTEGER NOT NULL,
|
success_count INTEGER NOT NULL,
|
||||||
anchor_rate_before SMALLINT NOT NULL,
|
anchoring_value_before SMALLINT NOT NULL,
|
||||||
anchor_rate_after SMALLINT NOT NULL,
|
anchoring_value_after SMALLINT NOT NULL,
|
||||||
consumed_session_ids JSONB NOT NULL,
|
used_session_ids JSONB NOT NULL,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
)""",
|
)""",
|
||||||
"""CREATE OR REPLACE VIEW anchoring.current_rates AS
|
"""CREATE OR REPLACE VIEW anchoring.current_values AS
|
||||||
SELECT DISTINCT ON (company_id, supplier_type, price_bracket_index)
|
SELECT DISTINCT ON (company_id, supplier_type, price_range_index)
|
||||||
company_id, supplier_type, price_bracket_index,
|
company_id, supplier_type, price_range_index,
|
||||||
anchor_rate_after AS anchor_rate_permille,
|
anchoring_value_after AS anchoring_value,
|
||||||
id AS last_adjustment_id, created_at AS last_adjusted_at
|
adjustment_id AS last_adjustment_id, created_at AS last_adjusted_at
|
||||||
FROM anchoring.rate_adjustments
|
FROM anchoring.adjustments
|
||||||
ORDER BY company_id, supplier_type, price_bracket_index, id DESC""",
|
ORDER BY company_id, supplier_type, price_range_index, adjustment_id DESC""",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -192,15 +192,15 @@ async def _reset_anchoring(engine):
|
|||||||
await conn.execute(text(ddl))
|
await conn.execute(text(ddl))
|
||||||
|
|
||||||
|
|
||||||
async def _seed_adjustment(engine, company_id, *, supplier_type, bracket, rate_after):
|
async def _seed_adjustment(engine, company_id, *, supplier_type, price_range, value_after):
|
||||||
"""칸에 조정 이력 1행 삽입(배치가 쌓는 행의 최소 재현)."""
|
"""칸에 조정 이력 1행 삽입(배치가 쌓는 행의 최소 재현)."""
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
text(
|
text(
|
||||||
"INSERT INTO anchoring.rate_adjustments "
|
"INSERT INTO anchoring.adjustments "
|
||||||
"(company_id, supplier_type, price_bracket_index, nego_count, success_count, "
|
"(company_id, supplier_type, price_range_index, sample_count, success_count, "
|
||||||
" anchor_rate_before, anchor_rate_after, consumed_session_ids) "
|
" anchoring_value_before, anchoring_value_after, used_session_ids) "
|
||||||
"VALUES (:cid, :stype, :bracket, 10, 8, 10, :after, '[]'::jsonb)"
|
"VALUES (:cid, :stype, :price_range, 10, 8, 10, :after, '[]'::jsonb)"
|
||||||
),
|
),
|
||||||
{"cid": uuid.UUID(company_id), "stype": supplier_type, "bracket": bracket, "after": rate_after},
|
{"cid": uuid.UUID(company_id), "stype": supplier_type, "price_range": price_range, "after": value_after},
|
||||||
)
|
)
|
||||||
|
|||||||
@ -317,21 +317,21 @@ export * from './resSupplierListMsg';
|
|||||||
export * from './resSupplierMsg';
|
export * from './resSupplierMsg';
|
||||||
export * from './resSupplierSupplier';
|
export * from './resSupplierSupplier';
|
||||||
export * from './resTargetBreakdown';
|
export * from './resTargetBreakdown';
|
||||||
|
export * from './resTargetBreakdownAnchoringPrice';
|
||||||
export * from './resTargetBreakdownChosenBasis';
|
export * from './resTargetBreakdownChosenBasis';
|
||||||
export * from './resTargetBreakdownInternetLowest';
|
export * from './resTargetBreakdownInternetLowest';
|
||||||
export * from './resTargetBreakdownMdPrice';
|
export * from './resTargetBreakdownMdPrice';
|
||||||
export * from './resTargetBreakdownMsg';
|
export * from './resTargetBreakdownMsg';
|
||||||
export * from './resTargetBreakdownPurchase';
|
export * from './resTargetBreakdownPurchase';
|
||||||
export * from './resTargetBreakdownSelling';
|
export * from './resTargetBreakdownSelling';
|
||||||
export * from './resTargetBreakdownTargetAnchoringPrice';
|
|
||||||
export * from './sessionData';
|
export * from './sessionData';
|
||||||
|
export * from './sessionDataAnchoringPrice';
|
||||||
export * from './sessionDataBidAt';
|
export * from './sessionDataBidAt';
|
||||||
export * from './sessionDataBidPrice';
|
export * from './sessionDataBidPrice';
|
||||||
export * from './sessionDataEmailSentAt';
|
export * from './sessionDataEmailSentAt';
|
||||||
export * from './sessionDataRejectDeliveryType';
|
export * from './sessionDataRejectDeliveryType';
|
||||||
export * from './sessionDataRejectPrice';
|
export * from './sessionDataRejectPrice';
|
||||||
export * from './sessionDataRejectReason';
|
export * from './sessionDataRejectReason';
|
||||||
export * from './sessionDataTargetAnchoringPrice';
|
|
||||||
export * from './sessionStatus';
|
export * from './sessionStatus';
|
||||||
export * from './supplierData';
|
export * from './supplierData';
|
||||||
export * from './supplierDataCode';
|
export * from './supplierDataCode';
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import type { ResTargetBreakdownPurchase } from './resTargetBreakdownPurchase';
|
|||||||
import type { ResTargetBreakdownSelling } from './resTargetBreakdownSelling';
|
import type { ResTargetBreakdownSelling } from './resTargetBreakdownSelling';
|
||||||
import type { TargetCandidate } from './targetCandidate';
|
import type { TargetCandidate } from './targetCandidate';
|
||||||
import type { ResTargetBreakdownChosenBasis } from './resTargetBreakdownChosenBasis';
|
import type { ResTargetBreakdownChosenBasis } from './resTargetBreakdownChosenBasis';
|
||||||
import type { ResTargetBreakdownTargetAnchoringPrice } from './resTargetBreakdownTargetAnchoringPrice';
|
import type { ResTargetBreakdownAnchoringPrice } from './resTargetBreakdownAnchoringPrice';
|
||||||
|
|
||||||
export interface ResTargetBreakdown {
|
export interface ResTargetBreakdown {
|
||||||
result?: ErrorInfo;
|
result?: ErrorInfo;
|
||||||
@ -25,8 +25,9 @@ export interface ResTargetBreakdown {
|
|||||||
selling?: ResTargetBreakdownSelling;
|
selling?: ResTargetBreakdownSelling;
|
||||||
fee?: number;
|
fee?: number;
|
||||||
margin?: number;
|
margin?: number;
|
||||||
|
anchoring_value?: number;
|
||||||
candidates?: TargetCandidate[];
|
candidates?: TargetCandidate[];
|
||||||
chosen_basis?: ResTargetBreakdownChosenBasis;
|
chosen_basis?: ResTargetBreakdownChosenBasis;
|
||||||
target_price?: number;
|
target_price?: number;
|
||||||
target_anchoring_price?: ResTargetBreakdownTargetAnchoringPrice;
|
anchoring_price?: ResTargetBreakdownAnchoringPrice;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,4 +5,4 @@
|
|||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type ResTargetBreakdownTargetAnchoringPrice = number | null;
|
export type ResTargetBreakdownAnchoringPrice = number | null;
|
||||||
@ -5,7 +5,7 @@
|
|||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
import type { QuotationType } from './quotationType';
|
import type { QuotationType } from './quotationType';
|
||||||
import type { SessionDataTargetAnchoringPrice } from './sessionDataTargetAnchoringPrice';
|
import type { SessionDataAnchoringPrice } from './sessionDataAnchoringPrice';
|
||||||
import type { SessionStatus } from './sessionStatus';
|
import type { SessionStatus } from './sessionStatus';
|
||||||
import type { SessionDataBidPrice } from './sessionDataBidPrice';
|
import type { SessionDataBidPrice } from './sessionDataBidPrice';
|
||||||
import type { SessionDataBidAt } from './sessionDataBidAt';
|
import type { SessionDataBidAt } from './sessionDataBidAt';
|
||||||
@ -23,7 +23,7 @@ export interface SessionData {
|
|||||||
qt_round: number;
|
qt_round: number;
|
||||||
qt_type: QuotationType;
|
qt_type: QuotationType;
|
||||||
target_price: number;
|
target_price: number;
|
||||||
target_anchoring_price?: SessionDataTargetAnchoringPrice;
|
anchoring_price?: SessionDataAnchoringPrice;
|
||||||
status: SessionStatus;
|
status: SessionStatus;
|
||||||
bid_price?: SessionDataBidPrice;
|
bid_price?: SessionDataBidPrice;
|
||||||
bid_at?: SessionDataBidAt;
|
bid_at?: SessionDataBidAt;
|
||||||
|
|||||||
@ -5,4 +5,4 @@
|
|||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type SessionDataTargetAnchoringPrice = number | null;
|
export type SessionDataAnchoringPrice = number | null;
|
||||||
@ -104,7 +104,7 @@ export function DrawerHeaderCards({
|
|||||||
<span className="font-sans font-bold text-foreground">{won(repSession?.target_price)}</span>
|
<span className="font-sans font-bold text-foreground">{won(repSession?.target_price)}</span>
|
||||||
)}
|
)}
|
||||||
</InfoField>
|
</InfoField>
|
||||||
<InfoField label="앵커링가" value={won(repSession?.target_anchoring_price)} valueClassName="font-sans" />
|
<InfoField label="앵커링가" value={won(repSession?.anchoring_price)} valueClassName="font-sans" />
|
||||||
<InfoField label="견적상태" labelClassName="opacity-90 font-bold mb-1">
|
<InfoField label="견적상태" labelClassName="opacity-90 font-bold mb-1">
|
||||||
<QuotationStatusBadge status={quotation.status} />
|
<QuotationStatusBadge status={quotation.status} />
|
||||||
</InfoField>
|
</InfoField>
|
||||||
|
|||||||
@ -181,12 +181,12 @@ export function SessionsStatusTab({
|
|||||||
<StatusPill tone={sessionStatusTone(sess.status)}>{sessionStatusLabel(sess.status)}</StatusPill>
|
<StatusPill tone={sessionStatusTone(sess.status)}>{sessionStatusLabel(sess.status)}</StatusPill>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="p-3 text-right text-muted-foreground">
|
<TableCell className="p-3 text-right text-muted-foreground">
|
||||||
{sess.target_anchoring_price > 0 ? (
|
{sess.anchoring_price > 0 ? (
|
||||||
<>
|
<>
|
||||||
₩{sess.target_anchoring_price.toLocaleString()}
|
₩{sess.anchoring_price.toLocaleString()}
|
||||||
{sess.target_price > 0 && (
|
{sess.target_price > 0 && (
|
||||||
<span className="ml-0.5 text-[10px] opacity-70">
|
<span className="ml-0.5 text-[10px] opacity-70">
|
||||||
({((1 - sess.target_anchoring_price / sess.target_price) * 100).toFixed(1)}%)
|
({((1 - sess.anchoring_price / sess.target_price) * 100).toFixed(1)}%)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -122,15 +122,16 @@ export function TargetPriceModal({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 앵커링 (negodata: 앵커링가는 칸 rate v1.2로 산정·박제, 세팅 고정율 폐지) */}
|
{/* 앵커링 (negodata: 설정 anchoring_value 고정율) */}
|
||||||
<div className="mt-4 bg-muted/40 border border-border rounded p-3 space-y-1">
|
<div className="mt-4 bg-muted/40 border border-border rounded p-3 space-y-1">
|
||||||
<Typography as="p" variant="small" className="font-bold text-foreground text-[11px]">앵커링</Typography>
|
<Typography as="p" variant="small" className="font-bold text-foreground text-[11px]">앵커링</Typography>
|
||||||
{category && (
|
{category && (
|
||||||
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">서비스 카테고리: {category}</Typography>
|
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">서비스 카테고리: {category}</Typography>
|
||||||
)}
|
)}
|
||||||
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">공급 업체 유형: {supplierTypeLabel}</Typography>
|
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">공급 업체 유형: {supplierTypeLabel}</Typography>
|
||||||
|
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">앵커링 값: {bd.anchoring_value}</Typography>
|
||||||
<Typography as="p" variant="small" className="text-[11px] font-bold text-foreground">
|
<Typography as="p" variant="small" className="text-[11px] font-bold text-foreground">
|
||||||
앵커링가: {won(bd.target_anchoring_price)} <span className="font-normal text-[10px] text-muted-foreground">= 목표가 × (1 − 앵커링율)</span>
|
앵커링가: {won(bd.anchoring_price)} <span className="font-normal text-[10px] text-muted-foreground">= 목표가×(1−{bd.anchoring_value})</span>
|
||||||
</Typography>
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -242,7 +242,7 @@ export type SessionView = {
|
|||||||
item_name: string;
|
item_name: string;
|
||||||
status: number;
|
status: number;
|
||||||
target_price: number;
|
target_price: number;
|
||||||
target_anchoring_price: number;
|
anchoring_price: number;
|
||||||
bid_price: number | null;
|
bid_price: number | null;
|
||||||
bid_at: string;
|
bid_at: string;
|
||||||
reject_reason: string | null;
|
reject_reason: string | null;
|
||||||
@ -379,7 +379,7 @@ export function mapServerSessionView(sd: SessionData, partners: Partner[], produ
|
|||||||
item_name: product?.name || '-',
|
item_name: product?.name || '-',
|
||||||
status: sd.status,
|
status: sd.status,
|
||||||
target_price: sd.target_price ?? 0,
|
target_price: sd.target_price ?? 0,
|
||||||
target_anchoring_price: sd.target_anchoring_price ?? 0,
|
anchoring_price: sd.anchoring_price ?? 0,
|
||||||
bid_price: sd.bid_price ?? null,
|
bid_price: sd.bid_price ?? null,
|
||||||
bid_at: sd.bid_at ? fmtDateTime(sd.bid_at) : '-',
|
bid_at: sd.bid_at ? fmtDateTime(sd.bid_at) : '-',
|
||||||
reject_reason: sd.reject_reason ?? null,
|
reject_reason: sd.reject_reason ?? null,
|
||||||
|
|||||||
@ -298,10 +298,10 @@ CREATE TABLE IF NOT EXISTS negotiation.sessions (
|
|||||||
qt_round INTEGER NOT NULL, -- 견적 라운드(스냅샷)
|
qt_round INTEGER NOT NULL, -- 견적 라운드(스냅샷)
|
||||||
qt_type SMALLINT NOT NULL, -- 견적 유형(스냅샷, QuotationType): 1=renego(재협상 1:1), 2=requote(재견적 1:N), 3=new_nego(신규협상 1:1), 4=new_quote(신규견적 1:N)
|
qt_type SMALLINT NOT NULL, -- 견적 유형(스냅샷, QuotationType): 1=renego(재협상 1:1), 2=requote(재견적 1:N), 3=new_nego(신규협상 1:1), 4=new_quote(신규견적 1:N)
|
||||||
target_price BIGINT NOT NULL, -- 목표가(원)
|
target_price BIGINT NOT NULL, -- 목표가(원)
|
||||||
target_anchoring_price BIGINT NULL, -- 앵커링가(원) — 생성 시 박제(schedules/anchoring 참조)
|
anchoring_price BIGINT NULL, -- 앵커링가(원) — 생성 시 박제(schedules/anchoring 참조)
|
||||||
anchor_rate_permille SMALLINT NULL, -- 제안 당시 앵커링 값(천분율) 박제
|
anchoring_value SMALLINT NULL, -- 제안 당시 앵커링 값(천분율‰) 박제
|
||||||
last_offered_price BIGINT NULL, -- 협력사 마지막 제시가(원) — 앵커링 표본 판정의 "가격 흔적"
|
last_offer_price BIGINT NULL, -- 협력사 마지막 제시가(원) — 앵커링 표본 판정의 "가격 흔적"
|
||||||
anchoring_adjustment_id BIGINT NULL, -- 앵커링 배치 소비 마킹(NULL=미처리 0=제외 >0=조정 id)
|
used_by_adjustment_id BIGINT NULL, -- 앵커링 배치 소비 마킹(NULL=미처리 0=제외 >0=조정 id)
|
||||||
status SMALLINT NOT NULL, -- 진행 상태(SessionStatus): 1=created(생성), 2=in_progress(진행중), 3=done(완료), 4=not_participated(미참여), 5=rejected(거부)
|
status SMALLINT NOT NULL, -- 진행 상태(SessionStatus): 1=created(생성), 2=in_progress(진행중), 3=done(완료), 4=not_participated(미참여), 5=rejected(거부)
|
||||||
bid_price BIGINT NULL, -- 입찰가(원)
|
bid_price BIGINT NULL, -- 입찰가(원)
|
||||||
bid_at TIMESTAMPTZ NULL, -- 입찰 시각
|
bid_at TIMESTAMPTZ NULL, -- 입찰 시각
|
||||||
@ -14,7 +14,7 @@ ALTER TABLE partner.items
|
|||||||
|
|
||||||
-- 세션: 앵커링가
|
-- 세션: 앵커링가
|
||||||
ALTER TABLE negotiation.sessions
|
ALTER TABLE negotiation.sessions
|
||||||
ADD COLUMN IF NOT EXISTS target_anchoring_price BIGINT;
|
ADD COLUMN IF NOT EXISTS anchoring_price BIGINT;
|
||||||
|
|
||||||
-- 견적: MD 제시가 + 협력사(공급채널) 유형
|
-- 견적: MD 제시가 + 협력사(공급채널) 유형
|
||||||
ALTER TABLE quotation.quotations
|
ALTER TABLE quotation.quotations
|
||||||
@ -54,12 +54,12 @@ CREATE INDEX IF NOT EXISTS idx_notifications_ref_qt_id ON company.notification
|
|||||||
|
|
||||||
-- ─────────────────────────────────────────────────────────────
|
-- ─────────────────────────────────────────────────────────────
|
||||||
-- [2026-07-02] 앵커링 v1.2 — sessions 판정·마킹 컬럼 3종
|
-- [2026-07-02] 앵커링 v1.2 — sessions 판정·마킹 컬럼 3종
|
||||||
-- (신규 DB 는 01-schema*.sql 에 반영됨. anchoring 스키마 자체(rate_adjustments·뷰)는
|
-- (신규 DB 는 01-schema*.sql 에 반영됨. anchoring 스키마 자체(adjustments·뷰)는
|
||||||
-- 모듈 소유 DDL schedules/anchoring/schema.sql 로 적용 — 여기엔 두지 않는다.)
|
-- 05-anchoring-schema.sql 로 적용 — 여기엔 두지 않는다.)
|
||||||
ALTER TABLE negotiation.sessions
|
ALTER TABLE negotiation.sessions
|
||||||
ADD COLUMN IF NOT EXISTS anchor_rate_permille SMALLINT NULL, -- 제안 당시 앵커링 값(천분율) 박제
|
ADD COLUMN IF NOT EXISTS anchoring_value SMALLINT NULL, -- 제안 당시 앵커링 값(천분율‰) 박제
|
||||||
ADD COLUMN IF NOT EXISTS last_offered_price BIGINT NULL, -- 협력사 마지막 제시가(가격 흔적)
|
ADD COLUMN IF NOT EXISTS last_offer_price BIGINT NULL, -- 협력사 마지막 제시가(가격 흔적)
|
||||||
ADD COLUMN IF NOT EXISTS anchoring_adjustment_id BIGINT NULL; -- 앵커링 배치 소비 마킹
|
ADD COLUMN IF NOT EXISTS used_by_adjustment_id BIGINT NULL; -- 앵커링 배치 소비 마킹
|
||||||
|
|
||||||
-- ─────────────────────────────────────────────────────────────
|
-- ─────────────────────────────────────────────────────────────
|
||||||
-- [2026-07-02] 마감 close_reason 개편 — 견적 마감사유 + 가격정책 3구간
|
-- [2026-07-02] 마감 close_reason 개편 — 견적 마감사유 + 가격정책 3구간
|
||||||
69
postgres-init/05-anchoring-schema.sql
Normal file
69
postgres-init/05-anchoring-schema.sql
Normal 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;
|
||||||
11
schedules/anchoring/.dockerignore
Normal file
11
schedules/anchoring/.dockerignore
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
# 시크릿 — 이미지 레이어에 절대 넣지 않는다(런타임 마운트/env 주입)
|
||||||
|
config.toml
|
||||||
|
config.*.toml
|
||||||
|
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache/
|
||||||
|
.venv/
|
||||||
|
tests/
|
||||||
|
docs/
|
||||||
|
*.md
|
||||||
1
schedules/anchoring/.gitignore
vendored
1
schedules/anchoring/.gitignore
vendored
@ -1,4 +1,5 @@
|
|||||||
config.toml
|
config.toml
|
||||||
|
config.*.toml
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
|||||||
@ -5,8 +5,8 @@ WORKDIR /app
|
|||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# config.{env}.toml 은 이미지에 넣지 않는다(시크릿이 이미지 레이어에 남음) — 런타임 마운트 또는 env 로 주입
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
COPY config.toml* ./
|
|
||||||
|
|
||||||
ENV PYTHONPATH=/app/src \
|
ENV PYTHONPATH=/app/src \
|
||||||
PYTHONUNBUFFERED=1
|
PYTHONUNBUFFERED=1
|
||||||
|
|||||||
@ -6,48 +6,48 @@
|
|||||||
|
|
||||||
> 규범 문서: **`docs/개발용.md`** (정책: `docs/기획용.md`, 흐름 해설: `docs/워크플로우.md`, 타 팀 적용: `docs/인수인계.md`)
|
> 규범 문서: **`docs/개발용.md`** (정책: `docs/기획용.md`, 흐름 해설: `docs/워크플로우.md`, 타 팀 적용: `docs/인수인계.md`)
|
||||||
> **처음 오신 분 / 운영 담당자** → **`docs/운영및유지보수.md`** 부터 보세요 (설치·실행·로그 읽기·트러블슈팅).
|
> **처음 오신 분 / 운영 담당자** → **`docs/운영및유지보수.md`** 부터 보세요 (설치·실행·로그 읽기·트러블슈팅).
|
||||||
> 과제 이력·백로그 → **`TODO.md`** (주요 과제는 전부 종결)
|
|
||||||
|
|
||||||
## 경계
|
## 경계
|
||||||
|
|
||||||
| 구분 | 대상 |
|
| 구분 | 대상 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 소유(쓰기) | `anchoring.rate_adjustments`(append-only 조정 이력), `sessions.anchoring_adjustment_id`(소비 마킹 — 이 컬럼만), Redis `anchor:*` 키 |
|
| 소유(쓰기) | `anchoring.adjustments`(append-only 조정 이력), `sessions.used_by_adjustment_id`(소비 마킹 — 이 컬럼만), Redis `anchor:*` 키 |
|
||||||
| 읽기 전용 | `negotiation.sessions`(박제 컬럼), `quotation.quotations.supplier_type`, `partner.items.company_id` |
|
| 읽기 전용 | `negotiation.sessions`(박제 컬럼), `quotation.quotations.supplier_type`, `partner.items.company_id` |
|
||||||
| 소비자 | negodata 가 `reader.get_anchor_rate` 이식 + 이 Redis 를 참조해 세션 생성 시 앵커가 박제 (인수인계) |
|
| 소비자 | negodata 가 reader 이식판으로 세션 생성 시 앵커가 박제 — **적용 완료(2026-07-04), 이식판은 Redis 미사용**(`current_values` 뷰 직조회, 인수인계 §1) |
|
||||||
|
|
||||||
## 구조
|
## 구조
|
||||||
|
|
||||||
```
|
```
|
||||||
schema.sql # 모듈 소유 DDL (rate_adjustments + sessions 3컬럼) — psql 수동 적용
|
|
||||||
src/anchoring/
|
src/anchoring/
|
||||||
constants.py # 상수·enum (δ={1:20, 2:10, 3:15} — 제조/총판 스왑 주의)
|
constants.py # 상수·enum (δ={1:20, 2:10, 3:15} — 제조/총판 스왑 주의)
|
||||||
resources/anchoring_base.json # 정적 기본 테이블(46칸 사다리, 전부 10‰) — 불변, 시작값의 유일한 소스
|
resources/anchoring_base.json # 정적 기본 테이블(46칸 사다리, 전부 10‰) — 불변, 시작값의 유일한 소스
|
||||||
base_table.py # 로드+검증(실패 시 기동 중단)
|
base_table.py # 로드+검증(실패 시 기동 중단)
|
||||||
service.py # 순수 계산 (구간·앵커가·판정·평가) — negodata 이식 대상
|
service.py # 순수 계산 (구간·앵커가·판정·평가) — negodata 이식 대상
|
||||||
reader.py # 현재 rate 조회: Redis → 조정 이력 → 정적 테이블 — negodata 이식 대상
|
reader.py # 현재 anchoring_value 조회: Redis → 조정 이력 → 정적 테이블 — negodata 이식 대상
|
||||||
redis_client.py # TTL 7일, socket timeout 0.3s, 장애 시 DB 폴백
|
redis_client.py # TTL 7일, socket timeout 0.3s, 장애 시 DB 폴백
|
||||||
batch.py # 격주 평가: 캐시 re-SET → 스캔·파생 판정 → 조정 INSERT+마킹(한 트랜잭션, rowcount 롤백)
|
batch.py # 격주 평가: 캐시 re-SET → 스캔·파생 판정 → 조정 INSERT+마킹(한 트랜잭션, rowcount 롤백)
|
||||||
scheduler.py # 매주 토 00:00 트리거 (격주 게이트는 잡 내부 ISO 주차 홀짝)
|
scheduler.py # 매주 토 00:00 트리거 (격주 게이트는 잡 내부 ISO 주차 홀짝)
|
||||||
main.py # 엔트리 (상주 / --once)
|
main.py # 엔트리 (상주 / --once)
|
||||||
tests/ # 골든 벡터(test_core) + DB 통합(test_batch — 로컬 Postgres 없으면 자동 스킵)
|
tests/ # 골든 벡터(test_core) + DB 통합(test_batch — 로컬 Postgres 없으면 자동 스킵)
|
||||||
|
migrations/ # 기존 DB 이름 개편용 rename 마이그레이션 (DDL 자체는 postgres-init/05)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 실행
|
## 실행
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 0) DDL 적용 (신규 DB: postgres-init/01~04 이후)
|
# 0) DDL 적용 — 루트 postgres-init 로 이관됨 (신규 DB: 01~04 이후 05)
|
||||||
psql -h 127.0.0.1 -U postgres -d negosium_db -f schema.sql
|
psql -h 127.0.0.1 -U postgres -d negosium_db -f ../../postgres-init/05-anchoring-schema.sql
|
||||||
|
|
||||||
# 로컬(가상환경)
|
# 로컬(가상환경)
|
||||||
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
|
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
|
||||||
cp config.toml.example config.toml # DB/Redis 채우기 (env 로 대체 가능)
|
cp config.toml.example config.local.toml # DB/Redis 채우기 (APP_ENV 로 local/dev/prod 선택, env 로 대체 가능)
|
||||||
PYTHONPATH=src .venv/bin/python -m anchoring.main --once --dry-run # 예행 연습(DB/Redis 무변경)
|
PYTHONPATH=src .venv/bin/python -m anchoring.main --once --dry-run # 예행 연습(DB/Redis 무변경)
|
||||||
PYTHONPATH=src .venv/bin/python -m anchoring.main --once # 수동 1회(격주 게이트 무시)
|
PYTHONPATH=src .venv/bin/python -m anchoring.main --once # 수동 1회(격주 게이트 무시)
|
||||||
PYTHONPATH=src .venv/bin/python -m anchoring.main # 스케줄러 상주
|
PYTHONPATH=src .venv/bin/python -m anchoring.main # 스케줄러 상주
|
||||||
|
|
||||||
# 도커(자립 compose: redis 동봉)
|
# 도커 — 루트 docker-compose.yml 에 통합됨(anchoring + anchoring-redis). config.local.toml 은
|
||||||
docker compose up -d --build
|
# 이미지에 안 들어가고 마운트되므로 up 전에 파일이 먼저 있어야 한다 (레포 루트에서 실행)
|
||||||
|
(cd ../.. && docker compose up -d --build anchoring anchoring-redis)
|
||||||
|
|
||||||
# 테스트
|
# 테스트
|
||||||
PYTHONPATH=src .venv/bin/python -m pytest tests/ -q
|
PYTHONPATH=src .venv/bin/python -m pytest tests/ -q
|
||||||
@ -62,9 +62,9 @@ docker logs anchoring | grep -E "WARNING|ERROR" # 이상 신호만
|
|||||||
```
|
```
|
||||||
|
|
||||||
- 타임스탬프는 항상 KST. 회차마다 `조정 company=... n=13 성공=8 10‰→30‰ adj_id=26`(칸별 상세)과
|
- 타임스탬프는 항상 KST. 회차마다 `조정 company=... n=13 성공=8 10‰→30‰ adj_id=26`(칸별 상세)과
|
||||||
`회사요약 company=...`(테넌트별 집계) 라인이 남고, `adj_id` 로 `anchoring.rate_adjustments` 행과 교차 확인한다.
|
`회사요약 company=...`(테넌트별 집계) 라인이 남고, `adj_id` 로 `anchoring.adjustments` 행과 교차 확인한다.
|
||||||
- 칸 실패가 있으면 종료 요약이 WARNING 으로 승격된다 — "WARN 이상 알람" 룰에 걸린다.
|
- 칸 실패가 있으면 종료 요약이 WARNING 으로 승격된다 — "WARN 이상 알람" 룰에 걸린다.
|
||||||
- 로그 로테이션은 compose 에 설정됨(10MB × 5). 영구 감사 추적은 로그가 아니라 DB(조정 이력 ↔ 세션 마킹)가 담당.
|
- 로그 로테이션은 루트 compose 에 설정됨(10MB × 5). 영구 감사 추적은 로그가 아니라 DB(조정 이력 ↔ 세션 마킹)가 담당.
|
||||||
|
|
||||||
## 운영 런북
|
## 운영 런북
|
||||||
|
|
||||||
@ -72,6 +72,6 @@ docker logs anchoring | grep -E "WARNING|ERROR" # 이상 신호만
|
|||||||
재기동 후 `--once` 1회 실행으로 즉시 캐치업(격주 게이트만 무시, 정책 파라미터 불변).
|
재기동 후 `--once` 1회 실행으로 즉시 캐치업(격주 게이트만 무시, 정책 파라미터 불변).
|
||||||
- **Redis 유실/재기동**: 캐시는 파생값 — 매 실행(매주, 게이트 무관) 시작 시 조정 보유 칸 전체를 re-SET 하고
|
- **Redis 유실/재기동**: 캐시는 파생값 — 매 실행(매주, 게이트 무관) 시작 시 조정 보유 칸 전체를 re-SET 하고
|
||||||
TTL 7일이 보조하므로 자가 회복된다. 수동 복구가 필요하면 `--once`.
|
TTL 7일이 보조하므로 자가 회복된다. 수동 복구가 필요하면 `--once`.
|
||||||
- **가격 제시율 0% WARN**: backend 의 `last_offered_price` 기록 배선 유실 신호(학습 무증상 동결) — 즉시 점검.
|
- **가격 제시율 0% WARN**: backend 의 `last_offer_price` 기록 배선 유실 신호(학습 무증상 동결) — 즉시 점검.
|
||||||
- **박제 정합 불일치 WARN**: negodata 의 앵커 산출 이식 오류 의심(정수식 ≠ 박제 anchor) — `docs/인수인계.md` §1.3 점검 요청.
|
- **박제 정합 불일치 WARN**: negodata 의 앵커 산출 이식 오류 의심(정수식 ≠ 박제 anchor) — `docs/인수인계.md` §1.3 점검 요청.
|
||||||
- 조정 이력은 append-only — UPDATE/DELETE 금지. 배치가 sessions 에 쓰는 컬럼은 `anchoring_adjustment_id` 하나뿐.
|
- 조정 이력은 append-only — UPDATE/DELETE 금지. 배치가 sessions 에 쓰는 컬럼은 `used_by_adjustment_id` 하나뿐.
|
||||||
|
|||||||
@ -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` 설정을 적용할 것.
|
|
||||||
@ -1,5 +1,9 @@
|
|||||||
# anchoring 모듈 설정 — config.toml 로 복사 후 채운다 (config.toml 은 gitignore).
|
# anchoring 모듈 설정 템플릿 — 실행 환경에 맞게 복사 후 채운다 (전부 gitignore):
|
||||||
# 우선순위: env(DB_*/REDIS_*/LOG_LEVEL) > 이 파일 > 코드 기본값.
|
# cp config.toml.example config.local.toml # APP_ENV=local (기본)
|
||||||
|
# cp config.toml.example config.dev.toml # APP_ENV=dev
|
||||||
|
# cp config.toml.example config.prod.toml # APP_ENV=prod
|
||||||
|
# 우선순위: env(DB_*/REDIS_*/LOG_LEVEL) > 해당 파일 > 코드 기본값.
|
||||||
|
# local 은 파일이 없어도 기본값으로 동작, dev/prod 는 파일이 없으면 기동 중단.
|
||||||
|
|
||||||
log_level = "info"
|
log_level = "info"
|
||||||
|
|
||||||
|
|||||||
@ -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
|
|
||||||
@ -34,11 +34,11 @@
|
|||||||
| 기본 테이블 | **서비스 시작 시 메모리 로드되는 불변 정적 테이블** (`src/anchoring/resources/anchoring_base.json`, DB 저장 안 함, 절대 변경 안 함). 칸의 시작값 소스 |
|
| 기본 테이블 | **서비스 시작 시 메모리 로드되는 불변 정적 테이블** (`src/anchoring/resources/anchoring_base.json`, DB 저장 안 함, 절대 변경 안 함). 칸의 시작값 소스 |
|
||||||
| 가격구간 | **자릿수 계단식 사다리(46칸)** — 최하단 [0, 1,000) 1칸 + 자릿수(1천~1억, 5개)마다 폭 = 자릿수 시작값(상한의 10%)인 9칸("1천 원대·2천 원대 … 9천만 원대"). 상한 = **정확히 1억**, `target_price > 1억`은 전부 **마지막 인덱스(45)** 로 클램프 |
|
| 가격구간 | **자릿수 계단식 사다리(46칸)** — 최하단 [0, 1,000) 1칸 + 자릿수(1천~1억, 5개)마다 폭 = 자릿수 시작값(상한의 10%)인 9칸("1천 원대·2천 원대 … 9천만 원대"). 상한 = **정확히 1억**, `target_price > 1억`은 전부 **마지막 인덱스(45)** 로 클램프 |
|
||||||
| 멀티테넌시 | 앵커링 값은 **회사(company)별로 독립** — 칸 키에 `company_id`(uuid) 포함 |
|
| 멀티테넌시 | 앵커링 값은 **회사(company)별로 독립** — 칸 키에 `company_id`(uuid) 포함 |
|
||||||
| 표본 | **전용 테이블 없음.** 종료된 재협상 세션(`negotiation.sessions`)의 종료 후 불변 컬럼(`target_anchoring_price`, `anchor_rate_permille`, `last_offered_price`, `bid_price`, `status`)에서 배치 시점에 **파생 판정**한다. 판정 입력이 전부 확정 컬럼이므로 파생 결과는 결정적이다 |
|
| 표본 | **전용 테이블 없음.** 종료된 재협상 세션(`negotiation.sessions`)의 종료 후 불변 컬럼(`anchoring_price`, `anchoring_value`, `last_offer_price`, `bid_price`, `status`)에서 배치 시점에 **파생 판정**한다. 판정 입력이 전부 확정 컬럼이므로 파생 결과는 결정적이다 |
|
||||||
| 표본 기준 | **"가격 흔적"**: 협력사가 가격을 한 번이라도 써낸(`last_offered_price` 기록) 종료 재협상만 표본. 앵커 이하 합의 = 성공, 나머지(앵커 초과 합의·결렬·가격 쓰고 이탈) = 실패, 가격 흔적 없음 = 제외 |
|
| 표본 기준 | **"가격 흔적"**: 협력사가 가격을 한 번이라도 써낸(`last_offer_price` 기록) 종료 재협상만 표본. 앵커 이하 합의 = 성공, 나머지(앵커 초과 합의·결렬·가격 쓰고 이탈) = 실패, 가격 흔적 없음 = 제외 |
|
||||||
| 앵커 비노출 | agent 는 앵커가를 협력사에게 표시하지 않는다 — 정보 비대칭·상대 선제안 유도 전략. 앵커는 엔진 내부 체결 임계로만 동작 |
|
| 앵커 비노출 | agent 는 앵커가를 협력사에게 표시하지 않는다 — 정보 비대칭·상대 선제안 유도 전략. 앵커는 엔진 내부 체결 임계로만 동작 |
|
||||||
| 조정 이력 저장 | **append-only 조정 이력** `anchoring.rate_adjustments` 1개. 현재 값 = 칸의 최신 조정 행, Redis 캐시 |
|
| 조정 이력 저장 | **append-only 조정 이력** `anchoring.adjustments` 1개. 현재 값 = 칸의 최신 조정 행, Redis 캐시 |
|
||||||
| 소비 경계 | `sessions.anchoring_adjustment_id` 마킹(NULL=미처리/이월, 0=제외 확정, >0=소비한 조정 id). 조정 INSERT + 마킹 = **한 트랜잭션** |
|
| 소비 경계 | `sessions.used_by_adjustment_id` 마킹(NULL=미처리/이월, 0=제외 확정, >0=소비한 조정 id). 조정 INSERT + 마킹 = **한 트랜잭션** |
|
||||||
| 평가 트리거 | **격주 토요일 00:00 (KST)**, 자립 컨테이너의 APScheduler. 누적 유효 표본 ≥ 10인 칸만 평가 |
|
| 평가 트리거 | **격주 토요일 00:00 (KST)**, 자립 컨테이너의 APScheduler. 누적 유효 표본 ≥ 10인 칸만 평가 |
|
||||||
| 평가 방식 | **누적 전량 평가**: 미처리 유효 표본 전부(n건)로 `r = 성공/n` 계산 후 전량 소비. n < 10이면 마킹 없이 스킵 → 다음 주기 자연 이월 |
|
| 평가 방식 | **누적 전량 평가**: 미처리 유효 표본 전부(n건)로 `r = 성공/n` 계산 후 전량 소비. n < 10이면 마킹 없이 스킵 → 다음 주기 자연 이월 |
|
||||||
| 앵커링가 반올림 | **1원 단위 내림(floor)** — 정수 연산만 사용 |
|
| 앵커링가 반올림 | **1원 단위 내림(floor)** — 정수 연산만 사용 |
|
||||||
@ -83,8 +83,8 @@
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 구간 범위 | `idx` k의 구간 = **`[이전 upper_bound, upper_bound)`** 좌폐우개 (idx 1 은 `[0, 1,000)`) |
|
| 구간 범위 | `idx` k의 구간 = **`[이전 upper_bound, upper_bound)`** 좌폐우개 (idx 1 은 `[0, 1,000)`) |
|
||||||
| 경계값 소속 | `target_price`가 정확히 `upper_bound`와 같으면 **다음 idx** 소속. 예: 30,000원 → "3만 원대" 칸(idx 13) |
|
| 경계값 소속 | `target_price`가 정확히 `upper_bound`와 같으면 **다음 idx** 소속. 예: 30,000원 → "3만 원대" 칸(idx 13) |
|
||||||
| 내부 인덱스 변환 | `bracket_index = idx − 1` = `bisect_right(UPPER_BOUNDS, price)` (0-기반). DB·Redis·코드 내부는 `bracket_index` 사용 |
|
| 내부 인덱스 변환 | `price_range_index = idx − 1` = `bisect_right(UPPER_BOUNDS, price)` (0-기반). DB·Redis·코드 내부는 `price_range_index` 사용 |
|
||||||
| 상한 클램프 | `target_price ≥ 90,000,000` → 마지막 구간(idx 46, `bracket_index` 45). **1억 초과도 예외 없이 마지막 인덱스** |
|
| 상한 클램프 | `target_price ≥ 90,000,000` → 마지막 구간(idx 46, `price_range_index` 45). **1억 초과도 예외 없이 마지막 인덱스** |
|
||||||
| 시작값 | 칸의 시작 앵커링 값 = 해당 idx의 `anchoring_value` 천분율 변환 정수: `int(round(anchoring_value * 1000))`. 현재 전 구간 10‰ |
|
| 시작값 | 칸의 시작 앵커링 값 = 해당 idx의 `anchoring_value` 천분율 변환 정수: `int(round(anchoring_value * 1000))`. 현재 전 구간 10‰ |
|
||||||
| 기동 검증 | 로드 시 46행·idx 연속(1..46)·`upper_bound == constants.UPPER_BOUNDS[i]`(사다리 대조)·`0.01 ≤ anchoring_value ≤ 0.20` 검증, 실패 시 **기동 중단** (§13) |
|
| 기동 검증 | 로드 시 46행·idx 연속(1..46)·`upper_bound == constants.UPPER_BOUNDS[i]`(사다리 대조)·`0.01 ≤ anchoring_value ≤ 0.20` 검증, 실패 시 **기동 중단** (§13) |
|
||||||
|
|
||||||
@ -100,13 +100,13 @@
|
|||||||
```python
|
```python
|
||||||
# src/anchoring/constants.py
|
# src/anchoring/constants.py
|
||||||
|
|
||||||
ANCHOR_RATE_MIN = 10 # 하한 1%
|
ANCHORING_VALUE_MIN = 10 # 하한 1%
|
||||||
ANCHOR_RATE_MAX = 200 # 상한 20%
|
ANCHORING_VALUE_MAX = 200 # 상한 20%
|
||||||
# 시작값은 상수가 아니라 정적 테이블(§2)에서 로드
|
# 시작값은 상수가 아니라 정적 테이블(§2)에서 로드
|
||||||
|
|
||||||
# 유형별 조정폭 (올림·내림 대칭). 키 = quotations.supplier_type SMALLINT 코드
|
# 유형별 조정폭 (올림·내림 대칭). 키 = quotations.supplier_type SMALLINT 코드
|
||||||
# ⚠️ 스왑 주의: 2=제조=±1%, 3=총판=±1.5% (v1.1의 ENUM명 기준 표와 코드 순서가 다름)
|
# ⚠️ 스왑 주의: 2=제조=±1%, 3=총판=±1.5% (v1.1의 ENUM명 기준 표와 코드 순서가 다름)
|
||||||
DELTA_PERMILLE = {
|
ADJUSTMENT_STEP = {
|
||||||
1: 20, # 유통(DISTRIBUTION) ±2%
|
1: 20, # 유통(DISTRIBUTION) ±2%
|
||||||
2: 10, # 제조(MANUFACTURE) ±1%
|
2: 10, # 제조(MANUFACTURE) ±1%
|
||||||
3: 15, # 총판(SOLE_AGENCY/WHOLESALE) ±1.5%
|
3: 15, # 총판(SOLE_AGENCY/WHOLESALE) ±1.5%
|
||||||
@ -117,13 +117,13 @@ SAMPLE_THRESHOLD = 10 # 평가 최소 유효 표본 수 (미만이면
|
|||||||
# 가격구간: 자릿수 계단식 사다리 — 폭 = 구간 상한의 10%(선행 자릿수 밴드)
|
# 가격구간: 자릿수 계단식 사다리 — 폭 = 구간 상한의 10%(선행 자릿수 밴드)
|
||||||
PRICE_MAX = 100_000_000 # 정적 테이블 상한(1억). 이상 가격은 전부 마지막 인덱스
|
PRICE_MAX = 100_000_000 # 정적 테이블 상한(1억). 이상 가격은 전부 마지막 인덱스
|
||||||
UPPER_BOUNDS = (1_000, 2_000, ..., 10_000, 20_000, ..., 100_000_000) # 생성식으로 정의, 46개
|
UPPER_BOUNDS = (1_000, 2_000, ..., 10_000, 20_000, ..., 100_000_000) # 생성식으로 정의, 46개
|
||||||
BRACKET_COUNT = 46
|
PRICE_RANGE_COUNT = 46
|
||||||
BRACKET_INDEX_MAX = 45 # 0-기반 구간 인덱스 상한
|
PRICE_RANGE_INDEX_MAX = 45 # 0-기반 구간 인덱스 상한
|
||||||
|
|
||||||
EVAL_WEEK_PARITY = 0 # ISO 주차 % 2 == 0 인 토요일만 평가 (기준 고정. ISO 53주 해에
|
EVAL_WEEK_PARITY = 0 # ISO 주차 % 2 == 0 인 토요일만 평가 (기준 고정. ISO 53주 해에
|
||||||
# 같은 패리티 토요일이 연속될 수 있으나 누적 평가라 자가 치유)
|
# 같은 패리티 토요일이 연속될 수 있으나 누적 평가라 자가 치유)
|
||||||
|
|
||||||
MARK_EXCLUDED = 0 # sessions.anchoring_adjustment_id 제외 확정 마킹값
|
MARK_EXCLUDED = 0 # sessions.used_by_adjustment_id 제외 확정 마킹값
|
||||||
|
|
||||||
CACHE_TTL_SECONDS = 7 * 24 * 3600 # Redis 키 TTL(§7) — stale 잔존 방지 보조
|
CACHE_TTL_SECONDS = 7 * 24 * 3600 # Redis 키 TTL(§7) — stale 잔존 방지 보조
|
||||||
REDIS_SOCKET_TIMEOUT = 0.3 # 행(hang) 방지 — 초과 시 DB 폴백
|
REDIS_SOCKET_TIMEOUT = 0.3 # 행(hang) 방지 — 초과 시 DB 폴백
|
||||||
@ -133,7 +133,7 @@ REDIS_SOCKET_TIMEOUT = 0.3 # 행(hang) 방지 — 초과 시 DB 폴백
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
class SupplierType(Enum):
|
class SupplierType(Enum):
|
||||||
"""협력사 유형 코드. quotation.quotations.supplier_type / anchoring.rate_adjustments.supplier_type
|
"""협력사 유형 코드. quotation.quotations.supplier_type / anchoring.adjustments.supplier_type
|
||||||
(negodata SupplierType 과 동일 코드)"""
|
(negodata SupplierType 과 동일 코드)"""
|
||||||
NONE = 0 # 미지정 — 앵커링 칸 구성 불가(집계 제외)
|
NONE = 0 # 미지정 — 앵커링 칸 구성 불가(집계 제외)
|
||||||
DISTRIBUTION = 1 # 유통
|
DISTRIBUTION = 1 # 유통
|
||||||
@ -157,13 +157,13 @@ class AnchoringSampleType(Enum):
|
|||||||
|
|
||||||
### 4.1 칸(cell) 식별
|
### 4.1 칸(cell) 식별
|
||||||
|
|
||||||
칸 = **`(company_id, supplier_type, bracket_index)`** 3중 키. 회사·유형·구간별로 완전히 독립된 표본·조정 이력·값을 가진다.
|
칸 = **`(company_id, supplier_type, price_range_index)`** 3중 키. 회사·유형·구간별로 완전히 독립된 표본·조정 이력·값을 가진다.
|
||||||
|
|
||||||
```
|
```
|
||||||
bracket_index = min(bisect_right(UPPER_BOUNDS, target_price), 45)
|
price_range_index = min(bisect_right(UPPER_BOUNDS, target_price), 45)
|
||||||
```
|
```
|
||||||
|
|
||||||
- `bracket_index` 산출 기준 가격은 **목표가(target_price)** 다 (MUST). 9천만 원 이상은 전부 마지막 인덱스 45.
|
- `price_range_index` 산출 기준 가격은 **목표가(target_price)** 다 (MUST). 9천만 원 이상은 전부 마지막 인덱스 45.
|
||||||
- 칸 해석 소스: `company_id` = `partner.items.company_id` (세션의 item 소유 회사 = 갑), `supplier_type` = `quotation.quotations.supplier_type` (재협상 1:1 견적에 기록됨).
|
- 칸 해석 소스: `company_id` = `partner.items.company_id` (세션의 item 소유 회사 = 갑), `supplier_type` = `quotation.quotations.supplier_type` (재협상 1:1 견적에 기록됨).
|
||||||
- 같은 구간·유형이라도 회사가 다르면 **서로 다른 칸**. 회사 간 표본·값 공유 **MUST NOT**.
|
- 같은 구간·유형이라도 회사가 다르면 **서로 다른 칸**. 회사 간 표본·값 공유 **MUST NOT**.
|
||||||
- 신규 회사 온보딩 시 초기화 작업 불필요: 조정 이력 없는 칸은 자동으로 정적 테이블 시작값을 사용한다.
|
- 신규 회사 온보딩 시 초기화 작업 불필요: 조정 이력 없는 칸은 자동으로 정적 테이블 시작값을 사용한다.
|
||||||
@ -172,7 +172,7 @@ bracket_index = min(bisect_right(UPPER_BOUNDS, target_price), 45)
|
|||||||
### 4.2 앵커링가 계산
|
### 4.2 앵커링가 계산
|
||||||
|
|
||||||
```
|
```
|
||||||
anchor_price = target_price × (1000 − anchor_rate_permille) // 1000
|
anchor_price = target_price × (1000 − anchoring_value) // 1000
|
||||||
```
|
```
|
||||||
|
|
||||||
- `target_price`가 정수(원)이므로 위 식은 **정수 연산만으로 정확한 내림**을 보장한다.
|
- `target_price`가 정수(원)이므로 위 식은 **정수 연산만으로 정확한 내림**을 보장한다.
|
||||||
@ -189,29 +189,29 @@ anchor_price = target_price × (1000 − anchor_rate_permille) // 1000
|
|||||||
|
|
||||||
| 컬럼 | 의미 | 기록 시점 |
|
| 컬럼 | 의미 | 기록 시점 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `sessions.target_anchoring_price` | 제안 당시 앵커링가 (판정 기준) | negodata 세션 생성 시 1회 박제 (§9.1) |
|
| `sessions.anchoring_price` | 제안 당시 앵커링가 (판정 기준) | negodata 세션 생성 시 1회 박제 (§9.1) |
|
||||||
| `sessions.anchor_rate_permille` | 제안 당시 rate (가격에서 역산 불가 — 내림이 손실 연산) | 동상 |
|
| `sessions.anchoring_value` | 제안 당시 rate (가격에서 역산 불가 — 내림이 손실 연산) | 동상 |
|
||||||
| `sessions.last_offered_price` | 협력사 마지막 제시가 = **가격 흔적** (NULL = 가격을 써낸 적 없음) | backend 가 가격 입력 턴마다 갱신(§9.2), 종료 후 불변 |
|
| `sessions.last_offer_price` | 협력사 마지막 제시가 = **가격 흔적** (NULL = 가격을 써낸 적 없음) | backend 가 가격 입력 턴마다 갱신(§9.2), 종료 후 불변 |
|
||||||
| `sessions.status` / `bid_price` | 종료 상태 / 확정 투찰가 | 세션 종료 시 확정 |
|
| `sessions.status` / `bid_price` | 종료 상태 / 확정 투찰가 | 세션 종료 시 확정 |
|
||||||
|
|
||||||
판정 대상: `qt_type = 1(재협상)` AND `status ∈ {3 DONE, 4 NOT_PARTICIPATED, 5 REJECTED}` AND `deleted = false`.
|
판정 대상: `qt_type = 1(재협상)` AND `status ∈ {3 DONE, 4 NOT_PARTICIPATED, 5 REJECTED}` AND `deleted = false`.
|
||||||
|
|
||||||
| 판정 | 조건 | 유효 표본 | 성공 |
|
| 판정 | 조건 | 유효 표본 | 성공 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `BID_SUCCESS` | `status=DONE` AND `bid_price ≤ target_anchoring_price` | O | O |
|
| `BID_SUCCESS` | `status=DONE` AND `bid_price ≤ anchoring_price` | O | O |
|
||||||
| `BID_FAIL` | 가격 흔적 있음 AND 성공 아님 — 앵커 초과 합의(와일드카드 상단 등) / 결렬(REJECTED) / **가격 쓰고 이탈 → 일괄마감(NOT_PARTICIPATED)** | O | X |
|
| `BID_FAIL` | 가격 흔적 있음 AND 성공 아님 — 앵커 초과 합의(와일드카드 상단 등) / 결렬(REJECTED) / **가격 쓰고 이탈 → 일괄마감(NOT_PARTICIPATED)** | O | X |
|
||||||
| `EXCLUDED` | `last_offered_price IS NULL`(가격 흔적 없음 — 미참여·무가격 이탈·만료) 또는 앵커 박제 없음 | **X** | — |
|
| `EXCLUDED` | `last_offer_price IS NULL`(가격 흔적 없음 — 미참여·무가격 이탈·만료) 또는 앵커 박제 없음 | **X** | — |
|
||||||
|
|
||||||
- "유효 표본" = `EXCLUDED`가 아닌 것. 노출 개념은 쓰지 않는다 — agent 는 앵커를 표시하지 않으므로(비노출 전략) 이탈이 앵커 수준과 무관해, 가격 흔적 없는 이탈을 제외해도 편향이 없다.
|
- "유효 표본" = `EXCLUDED`가 아닌 것. 노출 개념은 쓰지 않는다 — agent 는 앵커를 표시하지 않으므로(비노출 전략) 이탈이 앵커 수준과 무관해, 가격 흔적 없는 이탈을 제외해도 편향이 없다.
|
||||||
- **왜 실패에 결렬·이탈이 반드시 포함돼야 하나**: 채팅 엔진이 체결 자체를 anchor 로 게이트하므로(`check_price_match`) DONE ≈ 성공이다. 실패 신호는 가격을 쓰고도 합의에 못 이른 결렬·이탈에 있다 — 이를 빼면 성공률이 구조적으로 ~100%가 되어 rate 가 상한까지 폭주한다.
|
- **왜 실패에 결렬·이탈이 반드시 포함돼야 하나**: 채팅 엔진이 체결 자체를 anchor 로 게이트하므로(`check_price_match`) DONE ≈ 성공이다. 실패 신호는 가격을 쓰고도 합의에 못 이른 결렬·이탈에 있다 — 이를 빼면 성공률이 구조적으로 ~100%가 되어 rate 가 상한까지 폭주한다.
|
||||||
- 판정 입력 컬럼은 종료 후 **절대 수정 금지** (MUST NOT — §12). `last_offered_price` 만 세션 진행 중 갱신되고 종료 후 불변이다. 입력이 확정값이므로 파생 판정은 시점 무관 결정적이다.
|
- 판정 입력 컬럼은 종료 후 **절대 수정 금지** (MUST NOT — §12). `last_offer_price` 만 세션 진행 중 갱신되고 종료 후 불변이다. 입력이 확정값이므로 파생 판정은 시점 무관 결정적이다.
|
||||||
|
|
||||||
### 4.4 평가 산식 (누적 전량 평가)
|
### 4.4 평가 산식 (누적 전량 평가)
|
||||||
|
|
||||||
배치 시점에 칸별로 수행한다.
|
배치 시점에 칸별로 수행한다.
|
||||||
|
|
||||||
```
|
```
|
||||||
pending = 해당 칸의 미처리(anchoring_adjustment_id IS NULL) 유효 표본 전부
|
pending = 해당 칸의 미처리(used_by_adjustment_id IS NULL) 유효 표본 전부
|
||||||
n = |pending|
|
n = |pending|
|
||||||
|
|
||||||
n < 10 → 평가하지 않음. 마킹도 하지 않음 → 다음 주기로 이월 (자동으로 4주, 6주, …치가 됨)
|
n < 10 → 평가하지 않음. 마킹도 하지 않음 → 다음 주기로 이월 (자동으로 4주, 6주, …치가 됨)
|
||||||
@ -221,16 +221,16 @@ n ≥ 10 → r = (pending 중 BID_SUCCESS 건수) / n
|
|||||||
delta = ┤ 0 if 0.30 ≤ r < 0.60
|
delta = ┤ 0 if 0.30 ≤ r < 0.60
|
||||||
└ −δ(p) if r < 0.30
|
└ −δ(p) if r < 0.30
|
||||||
|
|
||||||
anchor_rate_after = clamp(anchor_rate_before + delta, 10, 200)
|
anchoring_value_after = clamp(anchoring_value_before + delta, 10, 200)
|
||||||
→ 한 트랜잭션으로:
|
→ 한 트랜잭션으로:
|
||||||
① anchoring.rate_adjustments INSERT (n, success, before/after, consumed_session_ids 박제)
|
① anchoring.adjustments INSERT (n, success, before/after, used_session_ids 박제)
|
||||||
② 소비 세션 UPDATE sessions SET anchoring_adjustment_id = <조정 id>
|
② 소비 세션 UPDATE sessions SET used_by_adjustment_id = <조정 id>
|
||||||
WHERE session_id IN (...) AND anchoring_adjustment_id IS NULL ← rowcount = n 검증, 불일치 시 전체 롤백 (MUST)
|
WHERE session_id IN (...) AND used_by_adjustment_id IS NULL ← rowcount = n 검증, 불일치 시 전체 롤백 (MUST)
|
||||||
```
|
```
|
||||||
|
|
||||||
- **분모는 항상 실제 누적 건수 n** (10 고정 아님). 13건이 모였으면 13건 전체로 평가하고 전부 소비한다.
|
- **분모는 항상 실제 누적 건수 n** (10 고정 아님). 13건이 모였으면 13건 전체로 평가하고 전부 소비한다.
|
||||||
- delta = 0이어도, clamp에 막혀 값이 안 변해도 **조정 레코드는 반드시 INSERT**하고 표본을 소비(마킹)한다 (MUST).
|
- delta = 0이어도, clamp에 막혀 값이 안 변해도 **조정 레코드는 반드시 INSERT**하고 표본을 소비(마킹)한다 (MUST).
|
||||||
- "표본 소비" = 마킹. 물리 삭제 없음. `EXCLUDED`·칸 구성 불가 세션은 평가와 무관하게 `anchoring_adjustment_id = 0`으로 일괄 마킹해 재스캔을 방지한다.
|
- "표본 소비" = 마킹. 물리 삭제 없음. `EXCLUDED`·칸 구성 불가 세션은 평가와 무관하게 `used_by_adjustment_id = 0`으로 일괄 마킹해 재스캔을 방지한다.
|
||||||
- 한 칸은 한 배치에서 **최대 1회** 평가된다 → 값 변동은 배치당 최대 ±δ (자연 보장).
|
- 한 칸은 한 배치에서 **최대 1회** 평가된다 → 값 변동은 배치당 최대 ±δ (자연 보장).
|
||||||
|
|
||||||
### 4.5 현재 앵커링 값 조회
|
### 4.5 현재 앵커링 값 조회
|
||||||
@ -238,12 +238,12 @@ anchor_rate_after = clamp(anchor_rate_before + delta, 10, 200)
|
|||||||
값은 저장된 단일 상태가 아니라 **조정 이력의 최신 행**이다.
|
값은 저장된 단일 상태가 아니라 **조정 이력의 최신 행**이다.
|
||||||
|
|
||||||
```
|
```
|
||||||
rate = (칸의 최신 anchoring.rate_adjustments 행).anchor_rate_after
|
rate = (칸의 최신 anchoring.adjustments 행).anchoring_value_after
|
||||||
없으면 → 정적 테이블 시작값 (§2.1)
|
없으면 → 정적 테이블 시작값 (§2.1)
|
||||||
```
|
```
|
||||||
|
|
||||||
- 재현성: 조정 행에 박제된 `consumed_session_ids`(JSONB)와 sessions의 박제 컬럼으로 임의 과거 조정을 재검산할 수 있다. **조정 이력은 유일 진실 원천**이며 보호 대상이다 (백업 정책 적용 MUST).
|
- 재현성: 조정 행에 박제된 `used_session_ids`(JSONB)와 sessions의 박제 컬럼으로 임의 과거 조정을 재검산할 수 있다. **조정 이력은 유일 진실 원천**이며 보호 대상이다 (백업 정책 적용 MUST).
|
||||||
- 파라미터(δ, 경계) 소급 재계산: 조정 행에 박제된 `consumed_session_ids`를 그대로 쓰고 산식만 새 파라미터로 재적용한다. 소비 창을 재유도 **MUST NOT** (배치 시각 의존이므로 불가능).
|
- 파라미터(δ, 경계) 소급 재계산: 조정 행에 박제된 `used_session_ids`를 그대로 쓰고 산식만 새 파라미터로 재적용한다. 소비 창을 재유도 **MUST NOT** (배치 시각 의존이므로 불가능).
|
||||||
- 알려진 완화: `sessions` 행 자체가 소프트 삭제·수정되면 재검산 근거가 오염될 수 있다 → 박제 컬럼 불변 규칙(§12)이 방어선이다.
|
- 알려진 완화: `sessions` 행 자체가 소프트 삭제·수정되면 재검산 근거가 오염될 수 있다 → 박제 컬럼 불변 규칙(§12)이 방어선이다.
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -256,22 +256,22 @@ rate = (칸의 최신 anchoring.rate_adjustments 행).anchor_rate_after
|
|||||||
|
|
||||||
[견적/세션 생성 — negodata, 인수인계 §9.1]
|
[견적/세션 생성 — negodata, 인수인계 §9.1]
|
||||||
│ 칸 rate 조회(Redis→조정이력→정적 테이블) → anchor = tp×(1000−rate)//1000 (정수)
|
│ 칸 rate 조회(Redis→조정이력→정적 테이블) → anchor = tp×(1000−rate)//1000 (정수)
|
||||||
│ → 세션 INSERT 에 target_anchoring_price + anchor_rate_permille 박제 (재생성 상속 폐지)
|
│ → 세션 INSERT 에 anchoring_price + anchoring_value 박제 (재생성 상속 폐지)
|
||||||
▼
|
▼
|
||||||
[협상 채팅 — backend, §9.2 — anchoring 모듈 무의존]
|
[협상 채팅 — backend, §9.2 — anchoring 모듈 무의존]
|
||||||
│ 박제된 anchor 를 agent 에 전달 (NULL 이면 목표가 폴백 + WARN) — 앵커는 비노출(엔진 내부 임계)
|
│ 박제된 anchor 를 agent 에 전달 (NULL 이면 목표가 폴백 + WARN) — 앵커는 비노출(엔진 내부 임계)
|
||||||
│ 가격 입력 턴마다 last_offered_price 갱신 (가격 흔적)
|
│ 가격 입력 턴마다 last_offer_price 갱신 (가격 흔적)
|
||||||
▼
|
▼
|
||||||
negotiation.sessions ──────────────── 표본의 원천 (종료 후 불변 컬럼)
|
negotiation.sessions ──────────────── 표본의 원천 (종료 후 불변 컬럼)
|
||||||
│
|
│
|
||||||
│ 격주 토 00:00 배치(anchoring 서비스): 미처리 종료 세션 스캔 → 파생 판정(§4.3)
|
│ 격주 토 00:00 배치(anchoring 서비스): 미처리 종료 세션 스캔 → 파생 판정(§4.3)
|
||||||
│ → 칸별 유효 n ≥ 10 → 평가(§4.4) + 소비 마킹 (단일 세션 한 트랜잭션)
|
│ → 칸별 유효 n ≥ 10 → 평가(§4.4) + 소비 마킹 (단일 세션 한 트랜잭션)
|
||||||
▼
|
▼
|
||||||
anchoring.rate_adjustments ────────── 진실 원천 (INSERT only, consumed_session_ids·값 변화 박제)
|
anchoring.adjustments ────────── 진실 원천 (INSERT only, used_session_ids·값 변화 박제)
|
||||||
│
|
│
|
||||||
│ 배치가 평가한 칸 SET + 매주 조정 보유 칸 전체 re-SET(캐시 정합)
|
│ 배치가 평가한 칸 SET + 매주 조정 보유 칸 전체 re-SET(캐시 정합)
|
||||||
▼
|
▼
|
||||||
Redis anchor:{company_id}:{supplier_type}:{bracket_index} → rate(‰), TTL 7일
|
Redis anchor:{company_id}:{supplier_type}:{price_range_index} → rate(‰), TTL 7일
|
||||||
│
|
│
|
||||||
│ GET (miss 시 조정 이력 최신 행 → 없으면 정적 테이블)
|
│ GET (miss 시 조정 이력 최신 행 → 없으면 정적 테이블)
|
||||||
▼
|
▼
|
||||||
@ -279,7 +279,7 @@ Redis anchor:{company_id}:{supplier_type}:{bracket_index} → rate(‰), TTL 7
|
|||||||
```
|
```
|
||||||
|
|
||||||
- 조정 이력 테이블에 UPDATE / DELETE **MUST NOT**.
|
- 조정 이력 테이블에 UPDATE / DELETE **MUST NOT**.
|
||||||
- 배치가 `sessions`에 쓰는 것은 `anchoring_adjustment_id` **단 하나** — 다른 컬럼 수정 MUST NOT.
|
- 배치가 `sessions`에 쓰는 것은 `used_by_adjustment_id` **단 하나** — 다른 컬럼 수정 MUST NOT.
|
||||||
- 견적 생성·협상(읽기) 경로는 anchoring 상태를 변경하지 않는다(캐시 SET 제외).
|
- 견적 생성·협상(읽기) 경로는 anchoring 상태를 변경하지 않는다(캐시 SET 제외).
|
||||||
- 배치가 한 회 누락돼도 다음 배치가 더 큰 n으로 1스텝 평가하며 자연 복구된다. 별도 보정 절차 불필요.
|
- 배치가 한 회 누락돼도 다음 배치가 더 큰 n으로 1스텝 평가하며 자연 복구된다. 별도 보정 절차 불필요.
|
||||||
- Redis 불능 시에도 전 경로 동작 (읽기 = DB 폴백, 배치 SET = best effort — §7/§8).
|
- Redis 불능 시에도 전 경로 동작 (읽기 = DB 폴백, 배치 SET = best effort — §7/§8).
|
||||||
@ -288,20 +288,20 @@ Redis anchor:{company_id}:{supplier_type}:{bracket_index} → rate(‰), TTL 7
|
|||||||
|
|
||||||
## 6. DB 스키마
|
## 6. DB 스키마
|
||||||
|
|
||||||
프로젝트 컨벤션 준수: FK/CHECK/PG ENUM **없음**, SMALLINT 코드, uuid 키, TIMESTAMPTZ(UTC). DDL 은 **모듈 소유** — `schema.sql` 한 파일(스키마+테이블+sessions ALTER+인덱스, psql 수동 적용). `postgres-init` 에는 anchoring 파일을 두지 않는다.
|
프로젝트 컨벤션 준수: FK/CHECK/PG ENUM **없음**, SMALLINT 코드, uuid 키, TIMESTAMPTZ(UTC). DDL 은 `postgres-init/05-anchoring-schema.sql` 한 파일(스키마+테이블+뷰+인덱스, psql 수동 적용 — 2026-07-06 모듈 schema.sql 에서 이관). sessions 앵커링 컬럼은 `01-schema*.sql`·`04-alter*.sql` 소관. 소유 서비스는 여전히 이 모듈이다.
|
||||||
|
|
||||||
**네이밍 결정** — 기존 코드베이스 용어와 통일:
|
**네이밍 결정** — 기존 코드베이스 용어와 통일:
|
||||||
|
|
||||||
| 개념 | 명칭 | 이유 |
|
| 개념 | 명칭 | 이유 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 조정 이력 테이블 | **`anchoring.rate_adjustments`** | 스키마명(anchoring) 접두 중복 제거 + "값 조정 이력"이라는 실체 표현 |
|
| 조정 이력 테이블 | **`anchoring.adjustments`** | 스키마명(anchoring) 접두 중복 제거 + "값 조정 이력"이라는 실체 표현 |
|
||||||
| 협력사 유형 | **`supplier_type`** | 기존 `quotations.supplier_type`과 용어 통일 |
|
| 협력사 유형 | **`supplier_type`** | 기존 `quotations.supplier_type`과 용어 통일 |
|
||||||
| 가격구간 | **`price_bracket_index`** | 가격구간임을 명시 (코드 내부 변수는 `bracket_index`) |
|
| 가격구간 | **`price_range_index`** | 가격구간임을 명시 (코드 내부 변수는 `price_range_index`) |
|
||||||
| 표본 수 | **`nego_count`** | "협상 결과 n건" — 정책 문서 용어 |
|
| 표본 수 | **`sample_count`** | "협상 결과 n건" — 정책 문서 용어 |
|
||||||
| 값 변화 | **`anchor_rate_before` / `anchor_rate_after`** | `sessions.anchor_rate_permille`와 계열 통일 (‰) |
|
| 값 변화 | **`anchoring_value_before` / `anchoring_value_after`** | `sessions.anchoring_value`와 계열 통일 (‰) |
|
||||||
| 소비 창 | **`consumed_session_ids`** | "이 조정이 소비한 세션"임을 명시 |
|
| 소비 창 | **`used_session_ids`** | "이 조정이 소비한 세션"임을 명시 |
|
||||||
| 생성 시각 | **`created_at`** | 프로젝트 공통 감사 컬럼 관행 (append-only라 생성=평가 시각) |
|
| 생성 시각 | **`created_at`** | 프로젝트 공통 감사 컬럼 관행 (append-only라 생성=평가 시각) |
|
||||||
| 소비 마킹 | **`sessions.anchoring_adjustment_id`** | 조정 테이블명과 정합 |
|
| 소비 마킹 | **`sessions.used_by_adjustment_id`** | 조정 테이블명과 정합 |
|
||||||
|
|
||||||
### 6.1 조정 이력 (신설 — 유일한 새 테이블)
|
### 6.1 조정 이력 (신설 — 유일한 새 테이블)
|
||||||
|
|
||||||
@ -309,56 +309,56 @@ Redis anchor:{company_id}:{supplier_type}:{bracket_index} → rate(‰), TTL 7
|
|||||||
CREATE SCHEMA IF NOT EXISTS anchoring;
|
CREATE SCHEMA IF NOT EXISTS anchoring;
|
||||||
|
|
||||||
-- 앵커링 값 조정 이력. append-only — UPDATE/DELETE 금지(§5), updated_at/deleted 의도적 생략.
|
-- 앵커링 값 조정 이력. append-only — UPDATE/DELETE 금지(§5), updated_at/deleted 의도적 생략.
|
||||||
CREATE TABLE IF NOT EXISTS anchoring.rate_adjustments (
|
CREATE TABLE IF NOT EXISTS anchoring.adjustments (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
company_id uuid NOT NULL, -- 테넌트(partner.items.company_id 유래)
|
company_id uuid NOT NULL, -- 테넌트(partner.items.company_id 유래)
|
||||||
supplier_type SMALLINT NOT NULL, -- 1=유통(δ20) 2=제조(δ10) 3=총판(δ15)
|
supplier_type SMALLINT NOT NULL, -- 1=유통(δ20) 2=제조(δ10) 3=총판(δ15)
|
||||||
price_bracket_index INTEGER NOT NULL, -- 가격구간 0..45 자릿수 사다리 (앱 보장)
|
price_range_index INTEGER NOT NULL, -- 가격구간 0..45 자릿수 사다리 (앱 보장)
|
||||||
nego_count INTEGER NOT NULL, -- 유효 표본 수 n (>=10, 앱 보장)
|
sample_count INTEGER NOT NULL, -- 유효 표본 수 n (>=10, 앱 보장)
|
||||||
success_count INTEGER NOT NULL, -- n 중 성공(BID_SUCCESS) 건수
|
success_count INTEGER NOT NULL, -- n 중 성공(BID_SUCCESS) 건수
|
||||||
anchor_rate_before SMALLINT NOT NULL, -- 직전 값(‰) (이력 없었으면 정적 테이블 시작값)
|
anchoring_value_before SMALLINT NOT NULL, -- 직전 값(‰) (이력 없었으면 정적 테이블 시작값)
|
||||||
anchor_rate_after SMALLINT NOT NULL, -- 조정 후 값(‰), clamp [10,200] 앱 보장
|
anchoring_value_after SMALLINT NOT NULL, -- 조정 후 값(‰), clamp [10,200] 앱 보장
|
||||||
consumed_session_ids JSONB NOT NULL, -- 소비한 세션 uuid 배열(창 박제 — 재현성·감사)
|
used_session_ids JSONB NOT NULL, -- 소비한 세션 uuid 배열(창 박제 — 재현성·감사)
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
-- 현재 값 조회 최적화: 칸별 최신 조정
|
-- 현재 값 조회 최적화: 칸별 최신 조정
|
||||||
CREATE INDEX IF NOT EXISTS idx_rate_adjustments_cell
|
CREATE INDEX IF NOT EXISTS idx_adjustments_cell
|
||||||
ON anchoring.rate_adjustments (company_id, supplier_type, price_bracket_index, id DESC);
|
ON anchoring.adjustments (company_id, supplier_type, price_range_index, id DESC);
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6.2 sessions 확장 (기존 테이블 ALTER)
|
### 6.2 sessions 확장 (기존 테이블 ALTER)
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
ALTER TABLE negotiation.sessions
|
ALTER TABLE negotiation.sessions
|
||||||
ADD COLUMN IF NOT EXISTS anchor_rate_permille SMALLINT NULL, -- 제안 당시 rate(‰) 박제
|
ADD COLUMN IF NOT EXISTS anchoring_value SMALLINT NULL, -- 제안 당시 rate(‰) 박제
|
||||||
ADD COLUMN IF NOT EXISTS last_offered_price BIGINT NULL, -- 마지막 제시가(가격 흔적) — 가격 입력마다 갱신, 종료 후 불변
|
ADD COLUMN IF NOT EXISTS last_offer_price BIGINT NULL, -- 마지막 제시가(가격 흔적) — 가격 입력마다 갱신, 종료 후 불변
|
||||||
ADD COLUMN IF NOT EXISTS anchoring_adjustment_id BIGINT NULL; -- NULL=미처리 0=제외확정 >0=소비한 조정 id
|
ADD COLUMN IF NOT EXISTS used_by_adjustment_id BIGINT NULL; -- NULL=미처리 0=제외확정 >0=소비한 조정 id
|
||||||
|
|
||||||
-- 배치 스캔 최적화: 미처리 "재협상" 세션만 (qt_type=1 을 술어에 포함 MUST —
|
-- 배치 스캔 최적화: 미처리 "재협상" 세션만 (qt_type=1 을 술어에 포함 MUST —
|
||||||
-- 빼면 배치가 마킹하지 않는 비재협상 세션이 영구 잔류해 인덱스가 무한 성장)
|
-- 빼면 배치가 마킹하지 않는 비재협상 세션이 영구 잔류해 인덱스가 무한 성장)
|
||||||
CREATE INDEX IF NOT EXISTS idx_sessions_anchoring_pending
|
CREATE INDEX IF NOT EXISTS idx_sessions_anchoring_pending
|
||||||
ON negotiation.sessions (status)
|
ON negotiation.sessions (status)
|
||||||
WHERE anchoring_adjustment_id IS NULL AND deleted = false AND qt_type = 1;
|
WHERE used_by_adjustment_id IS NULL AND deleted = false AND qt_type = 1;
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6.3 조회용 뷰 (파생 — 상태 없음)
|
### 6.3 조회용 뷰 (파생 — 상태 없음)
|
||||||
|
|
||||||
회사별 값 변경 추적·현재값 조회는 신규 테이블 없이 **뷰**로 제공한다(진실 원천은 rate_adjustments 그대로):
|
회사별 값 변경 추적·현재값 조회는 신규 테이블 없이 **뷰**로 제공한다(진실 원천은 adjustments 그대로):
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
anchoring.rate_history -- 값 변경 이력 리스트업: 이전 값(anchor_rate_before)→새 값 + delta_permille·success_rate·created_at
|
anchoring.value_history -- 값 변경 이력 리스트업: 이전 값(anchoring_value_before)→새 값 + value_change·success_rate·created_at
|
||||||
anchoring.current_rates -- 칸별 현재값(최신 조정 행). 여기 없는 칸의 현재값 = 정적 테이블 시작값(10‰)
|
anchoring.current_values -- 칸별 현재값(최신 조정 행). 여기 없는 칸의 현재값 = 정적 테이블 시작값(10‰)
|
||||||
```
|
```
|
||||||
|
|
||||||
- 뷰는 파생이므로 append-only 보호 대상(§12)이 아니며, 필요 시 자유롭게 재정의할 수 있다.
|
- 뷰는 파생이므로 append-only 보호 대상(§12)이 아니며, 필요 시 자유롭게 재정의할 수 있다.
|
||||||
- "records 신규 테이블" 안은 검토 후 기각 — 요구(회사별 업데이트 이력 + 이전 값 판별)가 rate_adjustments 한 행(before→after 박제)으로 이미 충족되어, 테이블 추가는 동일 정보의 사본만 만든다.
|
- "records 신규 테이블" 안은 검토 후 기각 — 요구(회사별 업데이트 이력 + 이전 값 판별)가 adjustments 한 행(before→after 박제)으로 이미 충족되어, 테이블 추가는 동일 정보의 사본만 만든다.
|
||||||
|
|
||||||
주의사항:
|
주의사항:
|
||||||
|
|
||||||
- `sessions.target_anchoring_price`는 negodata 가 이미 생성 시 채우는 기존 컬럼 — 앵커가 박제로 그대로 활용(신규 컬럼 아님).
|
- `sessions.anchoring_price`는 negodata 가 이미 생성 시 채우는 기존 컬럼 — 앵커가 박제로 그대로 활용(신규 컬럼 아님).
|
||||||
- 신규 DB 구축 시 적용 순서: `postgres-init/01~04` → `schedules/anchoring/schema.sql` (IF NOT EXISTS 라 재적용 안전). **sessions 3컬럼은 backend ORM 이 참조하므로 `postgres-init/01-schema*.sql`·`04-alter*.sql` 에도 반영돼 있다**(backend 가 모듈 DDL 없이도 기동) — anchoring 스키마 자체(테이블·뷰)는 모듈 파일만이 소유.
|
- 신규 DB 구축 시 적용 순서: `postgres-init/01~05` (IF NOT EXISTS 라 재적용 안전). **sessions 3컬럼은 backend ORM 이 참조하므로 `postgres-init/01-schema*.sql`·`04-alter*.sql` 에도 반영돼 있다**(backend 가 모듈 DDL 없이도 기동) — anchoring 스키마 자체(테이블·뷰)는 모듈 파일만이 소유.
|
||||||
- backend 모델(`models.py`)에는 **sessions 3컬럼만 추가**한다 — `rate_adjustments` 모델은 backend 에 만들지 않는다(무의존). 배치용 ORM 은 모듈이 자체 보유(읽기전용 sessions/quotations/items 매핑 포함).
|
- backend 모델(`models.py`)에는 **sessions 3컬럼만 추가**한다 — `adjustments` 모델은 backend 에 만들지 않는다(무의존). 배치용 ORM 은 모듈이 자체 보유(읽기전용 sessions/quotations/items 매핑 포함).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -366,7 +366,7 @@ anchoring.current_rates -- 칸별 현재값(최신 조정 행). 여기 없는
|
|||||||
|
|
||||||
| 항목 | 규약 |
|
| 항목 | 규약 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 키 | `anchor:{company_id}:{supplier_type}:{bracket_index}` — supplier_type 은 **SMALLINT 코드값**. 예: `anchor:0b0e…:1:10` |
|
| 키 | `anchor:{company_id}:{supplier_type}:{price_range_index}` — supplier_type 은 **SMALLINT 코드값**. 예: `anchor:0b0e…:1:10` |
|
||||||
| 값 | 정수 천분율 문자열. 예: `"30"` |
|
| 값 | 정수 천분율 문자열. 예: `"30"` |
|
||||||
| TTL | **7일** (stale 잔존 방지 보조 — 주 1회 re-SET 가 주 방어선, §8) |
|
| TTL | **7일** (stale 잔존 방지 보조 — 주 1회 re-SET 가 주 방어선, §8) |
|
||||||
| 캐시 미스 | 조정 이력 최신 행 조회 → 없으면 정적 테이블 시작값 → **SET NX**(키 없을 때만) 후 사용 — 배치가 방금 쓴 새 값을 읽기 경로가 구값으로 되덮는 write-after-read 경합 방지 |
|
| 캐시 미스 | 조정 이력 최신 행 조회 → 없으면 정적 테이블 시작값 → **SET NX**(키 없을 때만) 후 사용 — 배치가 방금 쓴 새 값을 읽기 경로가 구값으로 되덮는 write-after-read 경합 방지 |
|
||||||
@ -377,16 +377,16 @@ anchoring.current_rates -- 칸별 현재값(최신 조정 행). 여기 없는
|
|||||||
- 캐시는 파생값이다. Redis flush가 발생해도 조정 이력에서 완전 복구 가능해야 한다 (MUST).
|
- 캐시는 파생값이다. Redis flush가 발생해도 조정 이력에서 완전 복구 가능해야 한다 (MUST).
|
||||||
- ⚠️ **stale 키는 "미스"가 나지 않는다**: 배치의 DB 커밋 후 SET 실패, 또는 Redis 가 옛 스냅샷(RDB/AOF)으로 재기동하면 옛 rate 가 계속 서빙된다. 그래서 TTL + 주간 re-SET 이중 방어가 MUST 다.
|
- ⚠️ **stale 키는 "미스"가 나지 않는다**: 배치의 DB 커밋 후 SET 실패, 또는 Redis 가 옛 스냅샷(RDB/AOF)으로 재기동하면 옛 rate 가 계속 서빙된다. 그래서 TTL + 주간 re-SET 이중 방어가 MUST 다.
|
||||||
- 멀티 인스턴스 동시 미스 → 결과 동일(최신 조정 행은 하나)하므로 락 불필요.
|
- 멀티 인스턴스 동시 미스 → 결과 동일(최신 조정 행은 하나)하므로 락 불필요.
|
||||||
- 클라이언트: `redis.asyncio` — 사용 주체는 **anchoring 서비스**(배치 SET/re-SET)와 **negodata**(reader GET, 인수인계). backend 는 Redis 를 쓰지 않는다. 설정은 모듈 `config.toml` + `REDIS_HOST/PORT/PASSWORD` env 오버라이드. Redis 인스턴스는 모듈 docker-compose 에 동봉(negodata 가 같은 인스턴스를 바라봄).
|
- 클라이언트: `redis.asyncio` — 사용 주체는 **anchoring 서비스**(배치 SET/re-SET)뿐이다. backend 는 Redis 를 쓰지 않고, **negodata 도 쓰지 않는다**(2026-07-04 적용된 이식판 reader 는 `current_values` 뷰 직조회 — 인수인계 §1. Redis 캐시 전체 제거가 후속 백로그로 확정됨). 설정은 모듈 `config.{APP_ENV}.toml`(local/dev/prod, 기본 local) + `REDIS_HOST/PORT/PASSWORD` env 오버라이드.
|
||||||
- 보안: 무인증 Redis 를 외부 네트워크에 노출 **MUST NOT** — 오염된 rate 는 실제 제안가를 왜곡한다. 모듈 compose 는 포트를 `127.0.0.1` 로만 바인딩한다. negodata 가 다른 호스트에서 접근해야 하는 배치라면 인증(requirepass)·네트워크 격리 적용 후 개방한다(TODO 백로그).
|
- 보안: 무인증 Redis 를 외부 네트워크에 노출 **MUST NOT** — 오염된 rate 는 실제 제안가를 왜곡한다. compose(루트 docker-compose.yml)는 포트를 `127.0.0.1` 로만 바인딩한다. negodata 가 다른 호스트에서 접근해야 하는 배치라면 인증(requirepass)·네트워크 격리 적용 후 개방한다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. 배치 잡 명세
|
## 8. 배치 잡 명세
|
||||||
|
|
||||||
- **러너**: `schedules/anchoring` **자립 컨테이너**의 APScheduler(AsyncIOScheduler, `Asia/Seoul`) — 자체 Dockerfile·docker-compose·config.toml 보유, backend 코드 import 없음. 단일 컨테이너가 곧 스케줄러라 중복 실행이 원천 차단되며(`coalesce=True`, `max_instances=1`, `misfire_grace_time=3600`), 진입점은 `python -m anchoring.main`(상주) / `python -m anchoring.main --once`(수동 1회, 게이트 무시) / `--once --dry-run`(예행 — 아래 dry-run 모드). `--once` 는 종료 상태가 `done`/`skipped`/`dry_run` 이 아니면(부분 실패 포함) **종료코드 1** 로 끝난다(cron·수동 실행 실패 감지).
|
- **러너**: `schedules/anchoring` **자립 컨테이너**의 APScheduler(AsyncIOScheduler, `Asia/Seoul`) — 자체 Dockerfile·config.{APP_ENV}.toml 보유(APP_ENV 로 local/dev/prod 선택, compose 는 루트 docker-compose.yml 에 통합), backend 코드 import 없음. 단일 컨테이너가 곧 스케줄러라 중복 실행이 원천 차단되며(`coalesce=True`, `max_instances=1`, `misfire_grace_time=3600`), 진입점은 `python -m anchoring.main`(상주) / `python -m anchoring.main --once`(수동 1회, 게이트 무시) / `--once --dry-run`(예행 — 아래 dry-run 모드). 플래그는 argparse 로 검증한다 — `--dry-run` 단독(상주에 dry-run 은 없음)·미지의 플래그(오타)는 **기동 전 즉시 에러(종료코드 2)**: 예행인 줄 알고 실제 변경 상주 스케줄러가 뜨는 사고를 차단. `--once` 는 종료 상태가 `done`/`skipped`/`dry_run` 이 아니면(부분 실패 포함) **종료코드 1** 로 끝난다(cron·수동 실행 실패 감지). config.{env}.toml 은 이미지에 넣지 않는다(.dockerignore 포함) — compose 가 읽기 전용 마운트하거나 env 로 주입. dev/prod 는 파일 부재 시 기동 즉시 중단(local 만 기본값 허용).
|
||||||
- **스케줄**: 매주 토 00:00 KST 트리거(`CronTrigger(day_of_week="sat", hour=0, minute=0)`) + 잡 내부에서 **ISO 주차 % 2 == EVAL_WEEK_PARITY** 격주 게이트 (기준 패리티는 상수 고정 MUST).
|
- **스케줄**: 매주 토 00:00 KST 트리거(`CronTrigger(day_of_week="sat", hour=0, minute=0)`) + 잡 내부에서 **ISO 주차 % 2 == EVAL_WEEK_PARITY** 격주 게이트 (기준 패리티는 상수 고정 MUST).
|
||||||
- **멱등성**: 소비 마킹이 담당 — 같은 배치가 2회 실행돼도 1회차가 마킹한 세션은 2회차 pending에서 빠져 n < 10 스킵. 마킹 UPDATE의 `AND anchoring_adjustment_id IS NULL` 조건 + **rowcount = n 검증(불일치 시 전체 롤백) MUST** 가 경합을 차단한다 — 유니크 가드가 없는 구조에서 이중 조정(+2δ)을 막는 유일한 방어선이므로 SHOULD 가 아니라 MUST 다.
|
- **멱등성**: 소비 마킹이 담당 — 같은 배치가 2회 실행돼도 1회차가 마킹한 세션은 2회차 pending에서 빠져 n < 10 스킵. 마킹 UPDATE의 `AND used_by_adjustment_id IS NULL` 조건 + **rowcount = n 검증(불일치 시 전체 롤백) MUST** 가 경합을 차단한다 — 유니크 가드가 없는 구조에서 이중 조정(+2δ)을 막는 유일한 방어선이므로 SHOULD 가 아니라 MUST 다.
|
||||||
- **원자성**: 조정 INSERT 와 세션 마킹은 **같은 DB 세션의 한 트랜잭션**에서 실행한다(MUST). 모듈은 자체 async 엔진(`session_scope`)을 쓰므로 자연 충족된다. (참고: backend 의 `DB_SESSION_MNG.execute_lambda_run`은 db_type 2개 이상을 거부하므로, 이 로직을 backend 로 옮길 경우 단일 DBType 세션으로 실행해야 한다.)
|
- **원자성**: 조정 INSERT 와 세션 마킹은 **같은 DB 세션의 한 트랜잭션**에서 실행한다(MUST). 모듈은 자체 async 엔진(`session_scope`)을 쓰므로 자연 충족된다. (참고: backend 의 `DB_SESSION_MNG.execute_lambda_run`은 db_type 2개 이상을 거부하므로, 이 로직을 backend 로 옮길 경우 단일 DBType 세션으로 실행해야 한다.)
|
||||||
|
|
||||||
```
|
```
|
||||||
@ -397,31 +397,32 @@ anchoring.current_rates -- 칸별 현재값(최신 조정 행). 여기 없는
|
|||||||
(SET 실패·Redis 옛 스냅샷 재기동으로 인한 stale 을 최대 1주 내 회복 — §7)
|
(SET 실패·Redis 옛 스냅샷 재기동으로 인한 stale 을 최대 1주 내 회복 — §7)
|
||||||
1. 미처리 종료 재협상 세션 스캔 (LEFT JOIN + ON 절 deleted 필터 — §10 SQL 참조):
|
1. 미처리 종료 재협상 세션 스캔 (LEFT JOIN + ON 절 deleted 필터 — §10 SQL 참조):
|
||||||
sessions s LEFT JOIN quotations q (deleted=false) LEFT JOIN items i (deleted=false)
|
sessions s LEFT JOIN quotations q (deleted=false) LEFT JOIN items i (deleted=false)
|
||||||
WHERE s.anchoring_adjustment_id IS NULL AND s.deleted = false
|
WHERE s.used_by_adjustment_id IS NULL AND s.deleted = false
|
||||||
AND s.qt_type = 1 AND s.status IN (3, 4, 5)
|
AND s.qt_type = 1 AND s.status IN (3, 4, 5)
|
||||||
2. 세션별 파생 판정(§4.3):
|
2. 세션별 파생 판정(§4.3):
|
||||||
- EXCLUDED 또는 칸 구성 불가(q.supplier_type ∉ {1,2,3} / company 미해석)
|
- EXCLUDED 또는 칸 구성 불가(q.supplier_type ∉ {1,2,3} / company 미해석)
|
||||||
→ anchoring_adjustment_id = 0 일괄 마킹 (재스캔 방지)
|
→ used_by_adjustment_id = 0 일괄 마킹 (재스캔 방지)
|
||||||
- 유효 표본 → 칸별 그룹 적재
|
- 유효 표본 → 칸별 그룹 적재
|
||||||
- 박제 정합 감시: rate 가 박제된 세션에 대해 tp×(1000−anchor_rate_permille)//1000 과
|
- 박제 정합 감시: rate 가 박제된 세션에 대해 calc_anchoring_price(tp, anchoring_value)(§4.2 정수식)와
|
||||||
박제 anchor 를 대조, 불일치 수를 세어 WARN("박제 정합 불일치 n건") + 요약 snapshot_mismatch
|
박제 anchor 를 대조, 불일치 수를 세어 WARN("박제 정합 불일치 n건") + 요약 snapshot_mismatch
|
||||||
— negodata 이식 오류(float 잔재·칸 해석 오류)를 적용 첫 주에 자동 감지. rate 미박제(전환기)는
|
— negodata 이식 오류(float 잔재·칸 해석 오류)를 적용 첫 주에 자동 감지. rate 미박제(전환기)는
|
||||||
검사 대상 아님. 판정 자체는 계속 박제 anchor 기준(§4.3 — 감시는 경고만, 판정을 바꾸지 않는다)
|
검사 대상 아님. 판정 자체는 계속 박제 anchor 기준(§4.3 — 감시는 경고만, 판정을 바꾸지 않는다)
|
||||||
3. 칸별 (유효 n ≥ 10 인 칸만, 칸 단위 독립 트랜잭션 — 한 칸 실패가 전파되지 않음):
|
3. 칸별 (유효 n ≥ 10 인 칸만, 칸 단위 독립 트랜잭션 — 한 칸 실패가 전파되지 않음):
|
||||||
anchor_rate_before = 최신 조정 anchor_rate_after (없으면 정적 테이블 시작값)
|
anchoring_value_before = 최신 조정 anchoring_value_after (없으면 정적 테이블 시작값)
|
||||||
anchor_rate_after = evaluate_pending(...) # §4.4 / §10
|
anchoring_value_after = evaluate_samples(...) # §4.4 / §10
|
||||||
① anchoring.rate_adjustments INSERT (consumed_session_ids 박제)
|
① anchoring.adjustments INSERT (used_session_ids 박제)
|
||||||
② 소비 세션 마킹 — rowcount ≠ n 이면 ①② 전체 롤백 (MUST)
|
② 소비 세션 마킹 — rowcount ≠ n 이면 ①② 전체 롤백 (MUST)
|
||||||
4. 커밋 후 Redis SET anchor:{c}:{p}:{b} = anchor_rate_after (best effort, TTL 7일)
|
4. 커밋 후 Redis SET anchor:{c}:{p}:{b} = anchoring_value_after (best effort, TTL 7일)
|
||||||
5. 결과 로그: 평가 칸 수 / 상승·유지·하락 / 상·하한 도달 / 이월 칸 수 / 제외 마킹 건수
|
5. 결과 로그: 평가 칸 수 / 상승·유지·하락 / clamp 포화(조정치가 상·하한 밖으로 나가 잘린 칸 —
|
||||||
+ 가격 제시율(종료 재협상 세션 중 last_offered_price 보유 비율) — 0% 면 WARN
|
경계값에서의 단순 '유지'는 세지 않음) / 이월 칸 수 / 제외 마킹 건수
|
||||||
|
+ 가격 제시율(종료 재협상 세션 중 last_offer_price 보유 비율) — 0% 면 WARN
|
||||||
(backend 의 가격 기록 배선 유실로 학습이 조용히 동결되는 무증상 고장 감지)
|
(backend 의 가격 기록 배선 유실로 학습이 조용히 동결되는 무증상 고장 감지)
|
||||||
```
|
```
|
||||||
|
|
||||||
**로그 규약** (운영 추적):
|
**로그 규약** (운영 추적):
|
||||||
|
|
||||||
- 출력 = stdout(컨테이너 json-file 드라이버, compose 에서 10MB×5 로테이션). 타임스탬프는 컨테이너 TZ 와 무관하게 **항상 KST(+0900)**.
|
- 출력 = stdout(컨테이너 json-file 드라이버, 루트 compose 에서 10MB×5 로테이션). 타임스탬프는 컨테이너 TZ 와 무관하게 **항상 KST(+0900)**.
|
||||||
- 모든 배치 라인에 `[batch {run_id}]` 태그(run_id = 시작 시각) → 회차 단위 grep. 칸·회사 라인은 `company= type= bracket=` key=value 형식 → **회사별 grep**(`grep company=<uuid>`).
|
- 모든 배치 라인에 `[batch {run_id}]` 태그(run_id = 시작 시각) → 회차 단위 grep. 칸·회사 라인은 `company= type= price_range=` key=value 형식 → **회사별 grep**(`grep company=<uuid>`).
|
||||||
- 라인 구성: 시작(ISO 주차·force) → 캐시 re-SET 칸 수 → 제외 마킹 건수 → **칸별 조정 상세**(`n= 성공= before‰→after‰ adj_id=` — DB 행과 교차 확인) → **회사요약**(회사당 1줄: 평가/상승/유지/하락/이월/실패/제외) → redis 실패 누계(WARN, 있을 때만) → 종료 요약.
|
- 라인 구성: 시작(ISO 주차·force) → 캐시 re-SET 칸 수 → 제외 마킹 건수 → **칸별 조정 상세**(`n= 성공= before‰→after‰ adj_id=` — DB 행과 교차 확인) → **회사요약**(회사당 1줄: 평가/상승/유지/하락/이월/실패/제외) → redis 실패 누계(WARN, 있을 때만) → 종료 요약.
|
||||||
- 레벨: 칸 실패 = ERROR(칸 키 포함, 격리됨) / `failed_cells > 0` 이면 종료 요약을 **WARNING 으로 승격**(“WARN 이상 알람” 정책 호환) / Redis 실패 WARN 은 연산별 처음 5건만 남기고 누계로 요약(폭주 억제) / 가격 제시율 0% = WARN / 박제 정합 불일치 = WARN.
|
- 레벨: 칸 실패 = ERROR(칸 키 포함, 격리됨) / `failed_cells > 0` 이면 종료 요약을 **WARNING 으로 승격**(“WARN 이상 알람” 정책 호환) / Redis 실패 WARN 은 연산별 처음 5건만 남기고 누계로 요약(폭주 억제) / 가격 제시율 0% = WARN / 박제 정합 불일치 = WARN.
|
||||||
- 상주 기동 시 다음 실행 예정 시각 로그, apscheduler 로거도 동일 핸들러에 연결(misfire 등 스케줄 이상 가시화).
|
- 상주 기동 시 다음 실행 예정 시각 로그, apscheduler 로거도 동일 핸들러에 연결(misfire 등 스케줄 이상 가시화).
|
||||||
@ -442,19 +443,22 @@ anchoring.current_rates -- 칸별 현재값(최신 조정 행). 여기 없는
|
|||||||
|
|
||||||
### 9.1 견적/세션 생성 — negodata (인수인계 대상)
|
### 9.1 견적/세션 생성 — negodata (인수인계 대상)
|
||||||
|
|
||||||
현재 negodata `_build_quotation`은 세션 생성 시 `target_anchoring_price`를 구 방식으로 채운다
|
> ✅ 2026-07-04 적용 완료(인수인계.md §1 참조). 적용된 이식판은 아래 2번의 Redis GET/SET 없이
|
||||||
|
> `current_values` 뷰 직조회 → 정적 테이블 폴백으로 동작한다(단순화 결정).
|
||||||
|
|
||||||
|
구(舊) negodata `_build_quotation`은 세션 생성 시 `anchoring_price`를 구 방식으로 채웠다
|
||||||
(신규: `int(tp * (1 - quotation_settings.anchoring_value))` float 계산 / 재생성: 직전 라운드 값 상속).
|
(신규: `int(tp * (1 - quotation_settings.anchoring_value))` float 계산 / 재생성: 직전 라운드 값 상속).
|
||||||
새 앵커링 모듈 전달 후 아래로 교체된다:
|
새 앵커링 모듈 전달 후 아래로 교체된다:
|
||||||
|
|
||||||
```
|
```
|
||||||
세션(상품 × 공급사) 생성 시마다:
|
세션(상품 × 공급사) 생성 시마다:
|
||||||
1. 칸 해석: company_id = items.company_id / supplier_type = quotations.supplier_type
|
1. 칸 해석: company_id = items.company_id / supplier_type = quotations.supplier_type
|
||||||
bracket_index = calc_bracket_index(target_price) # 자릿수 사다리 — service 모듈 함수 이식
|
price_range_index = calc_price_range_index(target_price) # 자릿수 사다리 — service 모듈 함수 이식
|
||||||
2. rate 조회 (모듈의 reader 이식):
|
2. rate 조회 (모듈의 reader 이식):
|
||||||
supplier_type ∈ {1,2,3} → Redis GET → miss: 조정 이력 최신 행 → 없으면 정적 테이블 → SET
|
supplier_type ∈ {1,2,3} → Redis GET → miss: 조정 이력 최신 행 → 없으면 정적 테이블 → SET
|
||||||
그 외(미지정 등) → 정적 테이블 시작값 (유일 폴백 — §12)
|
그 외(미지정 등) → 정적 테이블 시작값 (유일 폴백 — §12)
|
||||||
3. anchor_price = target_price × (1000 − rate) // 1000 ← 정수 연산 MUST (기존 float 식 폐기)
|
3. anchor_price = target_price × (1000 − rate) // 1000 ← 정수 연산 MUST (기존 float 식 폐기)
|
||||||
4. 세션 INSERT 에 target_anchoring_price = anchor_price, anchor_rate_permille = rate 포함 (박제)
|
4. 세션 INSERT 에 anchoring_price = anchor_price, anchoring_value = rate 포함 (박제)
|
||||||
```
|
```
|
||||||
|
|
||||||
- **재생성 상속 폐지 (MUST)**: 다음 라운드 세션도 생성 시점의 칸 rate 로 재계산한다(target_price 상속은 별개 정책으로 유지 가능). "라운드 간 앵커가 상속"은 새 정책(칸의 현재 rate)과 상충하므로 폐지.
|
- **재생성 상속 폐지 (MUST)**: 다음 라운드 세션도 생성 시점의 칸 rate 로 재계산한다(target_price 상속은 별개 정책으로 유지 가능). "라운드 간 앵커가 상속"은 새 정책(칸의 현재 rate)과 상충하므로 폐지.
|
||||||
@ -466,7 +470,7 @@ anchoring.current_rates -- 칸별 현재값(최신 조정 행). 여기 없는
|
|||||||
`backend/services/chat_service.py::_resolve_anchor_price` — `quotation_settings.anchoring_value` 읽기 **삭제**. `_agent_context`가 오프닝 seed·send 양쪽의 단일 진입점이다. backend 는 anchoring 모듈·Redis·정적 테이블을 일절 사용하지 않는다.
|
`backend/services/chat_service.py::_resolve_anchor_price` — `quotation_settings.anchoring_value` 읽기 **삭제**. `_agent_context`가 오프닝 seed·send 양쪽의 단일 진입점이다. backend 는 anchoring 모듈·Redis·정적 테이블을 일절 사용하지 않는다.
|
||||||
|
|
||||||
```
|
```
|
||||||
1. 박제값 사용 (MUST): sessions.target_anchoring_price 를 그대로 사용.
|
1. 박제값 사용 (MUST): sessions.anchoring_price 를 그대로 사용.
|
||||||
→ negodata 가 세션 생성 시 항상 박제하므로 이것이 정상 경로.
|
→ negodata 가 세션 생성 시 항상 박제하므로 이것이 정상 경로.
|
||||||
→ 세션 진행 중 배치 조정·재기동이 껴도 앵커 불변 ("제안 당시 값" 판정의 전제)
|
→ 세션 진행 중 배치 조정·재기동이 껴도 앵커 불변 ("제안 당시 값" 판정의 전제)
|
||||||
2. NULL 폴백 (데이터 이상 대비 — 사실상 발생하지 않음): anchor = target_price (무할인) + WARN 로그.
|
2. NULL 폴백 (데이터 이상 대비 — 사실상 발생하지 않음): anchor = target_price (무할인) + WARN 로그.
|
||||||
@ -480,7 +484,7 @@ anchoring.current_rates -- 칸별 현재값(최신 조정 행). 여기 없는
|
|||||||
**가격 흔적 기록** (MUST):
|
**가격 흔적 기록** (MUST):
|
||||||
|
|
||||||
- agent 는 **변경하지 않는다**. 앵커가는 협력사에게 표시하지 않고(비노출 전략 — 정보 비대칭·상대 선제안 유도) 엔진 내부 체결 임계로만 쓴다.
|
- agent 는 **변경하지 않는다**. 앵커가는 협력사에게 표시하지 않고(비노출 전략 — 정보 비대칭·상대 선제안 유도) 엔진 내부 체결 임계로만 쓴다.
|
||||||
- backend `send()`가 가격 입력 턴(`price is not None`)의 봇 메시지를 저장하는 트랜잭션에 `UPDATE sessions SET last_offered_price = :price WHERE session_id = :id`를 함께 넣는다 — 메시지 저장과 **원자적**, 매 가격 입력마다 덮어씀(종료 후 자연 불변). 이 컬럼이 표본 판정의 "가격 흔적"이며, 가격을 쓰고 중간 이탈해 일괄마감된 세션도 실패로 측정할 수 있게 한다(§4.3).
|
- backend `send()`가 가격 입력 턴(`price is not None`)의 봇 메시지를 저장하는 트랜잭션에 `UPDATE sessions SET last_offer_price = :price WHERE session_id = :id`를 함께 넣는다 — 메시지 저장과 **원자적**, 매 가격 입력마다 덮어씀(종료 후 자연 불변). 이 컬럼이 표본 판정의 "가격 흔적"이며, 가격을 쓰고 중간 이탈해 일괄마감된 세션도 실패로 측정할 수 있게 한다(§4.3).
|
||||||
- 이 경로에서 anchoring 상태 변경은 없다 (조정 이력·마킹은 배치 전용, 읽기 전용 MUST).
|
- 이 경로에서 anchoring 상태 변경은 없다 (조정 이력·마킹은 배치 전용, 읽기 전용 MUST).
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -492,46 +496,48 @@ anchoring.current_rates -- 칸별 현재값(최신 조정 행). 여기 없는
|
|||||||
from bisect import bisect_right
|
from bisect import bisect_right
|
||||||
|
|
||||||
from anchoring.constants import (
|
from anchoring.constants import (
|
||||||
ANCHOR_RATE_MIN, ANCHOR_RATE_MAX, DELTA_PERMILLE,
|
ANCHORING_VALUE_MIN, ANCHORING_VALUE_MAX, ADJUSTMENT_STEP,
|
||||||
SAMPLE_THRESHOLD, UPPER_BOUNDS, BRACKET_INDEX_MAX,
|
SAMPLE_THRESHOLD, UPPER_BOUNDS, PRICE_RANGE_INDEX_MAX,
|
||||||
AnchoringSampleType,
|
AnchoringSampleType,
|
||||||
)
|
)
|
||||||
from anchoring.base_table import get_base_rate_permille # 정적 테이블 조회 (§2)
|
from anchoring.base_table import get_base_anchoring_value # 정적 테이블 조회 (§2)
|
||||||
|
|
||||||
|
|
||||||
def calc_bracket_index(target_price: int) -> int:
|
def calc_price_range_index(target_price: int) -> int:
|
||||||
"""목표가 → 가격구간 인덱스(0-기반). §4.1 — 자릿수 계단식 사다리.
|
"""목표가 → 가격구간 인덱스(0-기반). §4.1 — 자릿수 계단식 사다리.
|
||||||
좌폐우개: 가격 == upper_bound 면 다음 칸. 1억 이상은 마지막 인덱스로 클램프.
|
좌폐우개: 가격 == upper_bound 면 다음 칸. 1억 이상은 마지막 인덱스로 클램프.
|
||||||
정적 테이블 idx = 반환값 + 1"""
|
정적 테이블 idx = 반환값 + 1"""
|
||||||
return min(bisect_right(UPPER_BOUNDS, target_price), BRACKET_INDEX_MAX)
|
return min(bisect_right(UPPER_BOUNDS, target_price), PRICE_RANGE_INDEX_MAX)
|
||||||
|
|
||||||
|
|
||||||
def calc_anchor_price(target_price: int, rate_permille: int) -> int:
|
def calc_anchoring_price(target_price: int, anchoring_value: int) -> int:
|
||||||
"""앵커링가 = 목표가 × (1 − A), 1원 단위 내림. §4.2 (정수 연산만)"""
|
"""앵커링가 = 목표가 × (1 − A), 1원 단위 내림. §4.2 (정수 연산만)"""
|
||||||
return target_price * (1000 - rate_permille) // 1000
|
return target_price * (1000 - anchoring_value) // 1000
|
||||||
|
|
||||||
|
|
||||||
def judge_sample_type(
|
def judge_sample_type(
|
||||||
is_done: bool, # sessions.status == DONE(3)
|
is_done: bool, # sessions.status == DONE(3)
|
||||||
bid_price: int | None, # 확정 투찰가(DONE 시)
|
bid_price: int | None, # 확정 투찰가(DONE 시)
|
||||||
last_offered_price: int | None, # 마지막 제시가 — NULL 이면 가격 흔적 없음
|
last_offer_price: int | None, # 마지막 제시가 — NULL 이면 가격 흔적 없음
|
||||||
anchor_price: int | None, # sessions.target_anchoring_price (박제 앵커)
|
anchor_price: int | None, # sessions.anchoring_price (박제 앵커)
|
||||||
) -> int:
|
) -> int:
|
||||||
"""배치 시점 파생 판정("가격 흔적" 기준). §4.3 — 입력이 전부 종료 후 불변 컬럼이라 결정적."""
|
"""배치 시점 파생 판정("가격 흔적" 기준). §4.3 — 입력이 전부 종료 후 불변 컬럼이라 결정적."""
|
||||||
if anchor_price is None or last_offered_price is None:
|
if anchor_price is None or last_offer_price is None:
|
||||||
return AnchoringSampleType.EXCLUDED.value
|
return AnchoringSampleType.EXCLUDED.value
|
||||||
if is_done and bid_price is not None and bid_price <= anchor_price:
|
if is_done and bid_price is not None and bid_price <= anchor_price:
|
||||||
return AnchoringSampleType.BID_SUCCESS.value
|
return AnchoringSampleType.BID_SUCCESS.value
|
||||||
return AnchoringSampleType.BID_FAIL.value
|
return AnchoringSampleType.BID_FAIL.value
|
||||||
|
|
||||||
|
|
||||||
def evaluate_pending(
|
def evaluate_samples(
|
||||||
rate_before: int,
|
value_before: int,
|
||||||
sample_types: list[int], # 미처리 유효 표본 전량의 판정 코드
|
sample_types: list[int], # 미처리 유효 표본 전량의 판정 코드
|
||||||
supplier_type: int, # SMALLINT 코드 1/2/3
|
supplier_type: int, # SMALLINT 코드 1/2/3
|
||||||
) -> int | None:
|
) -> tuple[int, bool] | None:
|
||||||
"""누적 전량 평가. §4.4
|
"""누적 전량 평가. §4.4
|
||||||
반환: anchor_rate_after (평가 수행 시) / None (n < 10, 스킵·이월)
|
반환: (anchoring_value_after, clamped) (평가 수행 시) / None (n < 10, 스킵·이월)
|
||||||
|
clamped: 조정치가 [하한, 상한] 밖으로 나가 잘렸는지 — 경계값에서의 '유지'와
|
||||||
|
구분되는 실제 포화 신호(운영 지표용).
|
||||||
호출 측은 None 이 아니면 [조정 INSERT + 소비 마킹] 한 트랜잭션 + 캐시 SET 을 수행한다.
|
호출 측은 None 이 아니면 [조정 INSERT + 소비 마킹] 한 트랜잭션 + 캐시 SET 을 수행한다.
|
||||||
"""
|
"""
|
||||||
n = len(sample_types)
|
n = len(sample_types)
|
||||||
@ -539,24 +545,25 @@ def evaluate_pending(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
success = sum(1 for s in sample_types if s == AnchoringSampleType.BID_SUCCESS.value)
|
success = sum(1 for s in sample_types if s == AnchoringSampleType.BID_SUCCESS.value)
|
||||||
delta = DELTA_PERMILLE[supplier_type]
|
delta = ADJUSTMENT_STEP[supplier_type]
|
||||||
|
|
||||||
# r ≥ 0.60 ↔ success*10 ≥ n*6 (정수 비교로 부동소수점 회피)
|
# r ≥ 0.60 ↔ success*10 ≥ n*6 (정수 비교로 부동소수점 회피)
|
||||||
if success * 10 >= n * 6:
|
if success * 10 >= n * 6:
|
||||||
adjusted = rate_before + delta
|
adjusted = value_before + delta
|
||||||
elif success * 10 < n * 3: # r < 0.30
|
elif success * 10 < n * 3: # r < 0.30
|
||||||
adjusted = rate_before - delta
|
adjusted = value_before - delta
|
||||||
else: # 0.30 ≤ r < 0.60
|
else: # 0.30 ≤ r < 0.60
|
||||||
adjusted = rate_before
|
adjusted = value_before
|
||||||
|
|
||||||
return max(ANCHOR_RATE_MIN, min(ANCHOR_RATE_MAX, adjusted))
|
value_after = max(ANCHORING_VALUE_MIN, min(ANCHORING_VALUE_MAX, adjusted))
|
||||||
|
return value_after, value_after != adjusted
|
||||||
|
|
||||||
|
|
||||||
def get_current_rate(latest_adjusted_rate: int | None, bracket_index: int) -> int:
|
def get_current_value(latest_adjusted_rate: int | None, price_range_index: int) -> int:
|
||||||
"""현재 앵커링 값. §4.5 — 조정 이력 없으면 정적 테이블 시작값."""
|
"""현재 앵커링 값. §4.5 — 조정 이력 없으면 정적 테이블 시작값."""
|
||||||
if latest_adjusted_rate is not None:
|
if latest_adjusted_rate is not None:
|
||||||
return latest_adjusted_rate
|
return latest_adjusted_rate
|
||||||
return get_base_rate_permille(bracket_index) # int(round(anchoring_value * 1000))
|
return get_base_anchoring_value(price_range_index) # int(round(anchoring_value * 1000))
|
||||||
```
|
```
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
@ -564,12 +571,12 @@ def get_current_rate(latest_adjusted_rate: int | None, bracket_index: int) -> in
|
|||||||
-- LEFT JOIN + ON 절 deleted 필터: 삭제·소실된 견적/상품의 세션은 칸 해석이 NULL 이 되어
|
-- LEFT JOIN + ON 절 deleted 필터: 삭제·소실된 견적/상품의 세션은 칸 해석이 NULL 이 되어
|
||||||
-- 제외 마킹(0)으로 정리된다 — 철회된 거래를 학습에 쓰지 않으면서 영구 재스캔도 방지.
|
-- 제외 마킹(0)으로 정리된다 — 철회된 거래를 학습에 쓰지 않으면서 영구 재스캔도 방지.
|
||||||
SELECT s.session_id, s.status, s.bid_price, s.target_price,
|
SELECT s.session_id, s.status, s.bid_price, s.target_price,
|
||||||
s.target_anchoring_price, s.last_offered_price,
|
s.anchoring_price, s.last_offer_price,
|
||||||
q.supplier_type, i.company_id
|
q.supplier_type, i.company_id
|
||||||
FROM negotiation.sessions s
|
FROM negotiation.sessions s
|
||||||
LEFT JOIN quotation.quotations q ON q.qt_id = s.quotation_id AND q.deleted = false
|
LEFT JOIN quotation.quotations q ON q.qt_id = s.quotation_id AND q.deleted = false
|
||||||
LEFT JOIN partner.items i ON i.item_id = s.item_id AND i.deleted = false
|
LEFT JOIN partner.items i ON i.item_id = s.item_id AND i.deleted = false
|
||||||
WHERE s.anchoring_adjustment_id IS NULL
|
WHERE s.used_by_adjustment_id IS NULL
|
||||||
AND s.deleted = false
|
AND s.deleted = false
|
||||||
AND s.qt_type = 1
|
AND s.qt_type = 1
|
||||||
AND s.status IN (3, 4, 5)
|
AND s.status IN (3, 4, 5)
|
||||||
@ -577,9 +584,9 @@ WHERE s.anchoring_adjustment_id IS NULL
|
|||||||
|
|
||||||
```sql
|
```sql
|
||||||
-- 현재 값 조회 (캐시 미스 시)
|
-- 현재 값 조회 (캐시 미스 시)
|
||||||
SELECT anchor_rate_after
|
SELECT anchoring_value_after
|
||||||
FROM anchoring.rate_adjustments
|
FROM anchoring.adjustments
|
||||||
WHERE company_id = :c AND supplier_type = :p AND price_bracket_index = :b
|
WHERE company_id = :c AND supplier_type = :p AND price_range_index = :b
|
||||||
ORDER BY id DESC
|
ORDER BY id DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
```
|
```
|
||||||
@ -601,7 +608,7 @@ LIMIT 1
|
|||||||
|
|
||||||
### 11.2 구간 인덱스 (정적 테이블 매핑·상한 클램프 포함)
|
### 11.2 구간 인덱스 (정적 테이블 매핑·상한 클램프 포함)
|
||||||
|
|
||||||
| target_price | bracket_index | 정적 테이블 idx | 칸 |
|
| target_price | price_range_index | 정적 테이블 idx | 칸 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 0 | 0 | 1 | [0, 1,000) 통일 칸 |
|
| 0 | 0 | 1 | [0, 1,000) 통일 칸 |
|
||||||
| 999 | 0 | 1 | [0, 1,000) |
|
| 999 | 0 | 1 | [0, 1,000) |
|
||||||
@ -616,9 +623,9 @@ LIMIT 1
|
|||||||
|
|
||||||
정적 테이블 검증: 46행 · idx 1..46 연속 · `upper_bound == UPPER_BOUNDS[i]`(사다리 대조) · 마지막 100,000,000.
|
정적 테이블 검증: 46행 · idx 1..46 연속 · `upper_bound == UPPER_BOUNDS[i]`(사다리 대조) · 마지막 100,000,000.
|
||||||
|
|
||||||
### 11.3 누적 전량 평가 (유통 코드1, δ=20, rate_before=10)
|
### 11.3 누적 전량 평가 (유통 코드1, δ=20, value_before=10)
|
||||||
|
|
||||||
| pending 구성 | n | r | 판정 | anchor_rate_after |
|
| pending 구성 | n | r | 판정 | anchoring_value_after |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| 성공 8 / 실패 5 | 13 | ≈ 0.615 | ≥ 0.60 → +20 | **30** |
|
| 성공 8 / 실패 5 | 13 | ≈ 0.615 | ≥ 0.60 → +20 | **30** |
|
||||||
| 성공 7 / 실패 6 | 13 | ≈ 0.538 | 유지 | **10** |
|
| 성공 7 / 실패 6 | 13 | ≈ 0.538 | 유지 | **10** |
|
||||||
@ -627,20 +634,20 @@ LIMIT 1
|
|||||||
| 성공 3 / 실패 7 | 10 | 0.30 정확히 | 유지 | **10** |
|
| 성공 3 / 실패 7 | 10 | 0.30 정확히 | 유지 | **10** |
|
||||||
| 성공 9 / 실패 0 | 9 | — | **평가 안 함 (이월)** | None |
|
| 성공 9 / 실패 0 | 9 | — | **평가 안 함 (이월)** | None |
|
||||||
|
|
||||||
**δ 스왑 가드 (MUST)**: `evaluate_pending(10, [성공10/10], supplier_type=2) == 20` (제조 +10), `supplier_type=3 → 25` (총판 +15).
|
**δ 스왑 가드 (MUST)**: `evaluate_samples(10, [성공10/10], supplier_type=2) == (20, False)` (제조 +10), `supplier_type=3 → (25, False)` (총판 +15).
|
||||||
|
|
||||||
clamp·격리 케이스:
|
clamp·격리 케이스:
|
||||||
|
|
||||||
| 시나리오 | 기대 |
|
| 시나리오 | 기대 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| rate_before 200, r = 0.9 | 200 유지 (상한 clamp), 조정 레코드는 INSERT + 표본 소비됨 |
|
| value_before 200, r = 0.9 | 200 유지 (상한 clamp), 조정 레코드는 INSERT + 표본 소비됨 |
|
||||||
| A사 칸 평가 | B사의 같은 (p, b) 칸 값에 영향 없음 |
|
| A사 칸 평가 | B사의 같은 (p, b) 칸 값에 영향 없음 |
|
||||||
| 조정 이력 없는 칸 | 정적 테이블 시작값(10) 반환 |
|
| 조정 이력 없는 칸 | 정적 테이블 시작값(10) 반환 |
|
||||||
| EXCLUDED 15건 + 유효 5건 | 평가 안 함 (유효 5 < 10), EXCLUDED 는 마킹 0 처리 |
|
| EXCLUDED 15건 + 유효 5건 | 평가 안 함 (유효 5 < 10), EXCLUDED 는 마킹 0 처리 |
|
||||||
|
|
||||||
### 11.4 파생 판정
|
### 11.4 파생 판정
|
||||||
|
|
||||||
| status | bid_price | last_offered_price | anchor_price | 기대 |
|
| status | bid_price | last_offer_price | anchor_price | 기대 |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| DONE | 24,000 | 24,000 | 24,000 | BID_SUCCESS (같아도 성공) |
|
| DONE | 24,000 | 24,000 | 24,000 | BID_SUCCESS (같아도 성공) |
|
||||||
| DONE | 24,001 | 24,001 | 24,000 | BID_FAIL (앵커 초과 합의 — 와일드카드 상단 등) |
|
| DONE | 24,001 | 24,001 | 24,000 | BID_FAIL (앵커 초과 합의 — 와일드카드 상단 등) |
|
||||||
@ -653,7 +660,7 @@ clamp·격리 케이스:
|
|||||||
|
|
||||||
| 시나리오 | 기대 |
|
| 시나리오 | 기대 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 유효 13건 시드 → 배치 | 조정 1행(n=13, consumed_session_ids 13개 박제, 10→30) + 13건 모두 `anchoring_adjustment_id`=조정 id |
|
| 유효 13건 시드 → 배치 | 조정 1행(n=13, used_session_ids 13개 박제, 10→30) + 13건 모두 `used_by_adjustment_id`=조정 id |
|
||||||
| 직후 배치 재실행 | 조정 0건 (전 칸 pending < 10 — 마킹 멱등) |
|
| 직후 배치 재실행 | 조정 0건 (전 칸 pending < 10 — 마킹 멱등) |
|
||||||
| 2주 차 7건 → 스킵(마킹 없음) → 4주 차 누적 13건 | 4주 차 배치에서 13건 전량 1회 평가 |
|
| 2주 차 7건 → 스킵(마킹 없음) → 4주 차 누적 13건 | 4주 차 배치에서 13건 전량 1회 평가 |
|
||||||
| 배치 1회 누락 → 다음 배치 | 4주치 pending으로 1스텝 평가, 별도 보정 불필요 |
|
| 배치 1회 누락 → 다음 배치 | 4주치 pending으로 1스텝 평가, 별도 보정 불필요 |
|
||||||
@ -670,8 +677,8 @@ clamp·격리 케이스:
|
|||||||
|
|
||||||
| 시나리오 | 기대 |
|
| 시나리오 | 기대 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 재협상 채팅 → 가격 입력 턴 | `last_offered_price` 가 입력가로 갱신(매 입력마다 덮어씀), 앵커는 화면에 비노출 (앵커가·rate 는 negodata 가 생성 시 박제) |
|
| 재협상 채팅 → 가격 입력 턴 | `last_offer_price` 가 입력가로 갱신(매 입력마다 덮어씀), 앵커는 화면에 비노출 (앵커가·rate 는 negodata 가 생성 시 박제) |
|
||||||
| 가격 쓰고 이탈 → 일괄마감(NOT_PARTICIPATED) | `last_offered_price` 보존 → 배치에서 BID_FAIL 표본 |
|
| 가격 쓰고 이탈 → 일괄마감(NOT_PARTICIPATED) | `last_offer_price` 보존 → 배치에서 BID_FAIL 표본 |
|
||||||
| 같은 세션에서 배치가 값 변경 후 다음 턴 | 앵커 불변 (박제값 사용) |
|
| 같은 세션에서 배치가 값 변경 후 다음 턴 | 앵커 불변 (박제값 사용) |
|
||||||
| 박제 없는 세션(NULL 폴백) | anchor = target_price(무할인) + WARN, 박제 안 함 → 배치에서 EXCLUDED. 가격 흔적 기록은 정상 동작 |
|
| 박제 없는 세션(NULL 폴백) | anchor = target_price(무할인) + WARN, 박제 안 함 → 배치에서 EXCLUDED. 가격 흔적 기록은 정상 동작 |
|
||||||
| Redis 정지 상태에서 견적 생성(negodata reader) | DB 폴백으로 정상 동작 (GET timeout 0.2~0.5s 내 폴백) |
|
| Redis 정지 상태에서 견적 생성(negodata reader) | DB 폴백으로 정상 동작 (GET timeout 0.2~0.5s 내 폴백) |
|
||||||
@ -685,7 +692,7 @@ clamp·격리 케이스:
|
|||||||
- **극희소 칸 fallback** (상위 구간 값 상속 등) — 정책 미확정. 조정 이력 없는 칸은 무조건 정적 테이블 시작값 (MUST NOT 구현).
|
- **극희소 칸 fallback** (상위 구간 값 상속 등) — 정책 미확정. 조정 이력 없는 칸은 무조건 정적 테이블 시작값 (MUST NOT 구현).
|
||||||
- **정적 기본 테이블 변경** — 런타임·배포 중 값 수정 금지. 테이블 변경은 정책 재확정 사안.
|
- **정적 기본 테이블 변경** — 런타임·배포 중 값 수정 금지. 테이블 변경은 정책 재확정 사안.
|
||||||
- **조정 이력의 UPDATE/DELETE, 소급 무효화·보정** — 필요 사례 확인 시 보정 이벤트 방식으로 별도 설계.
|
- **조정 이력의 UPDATE/DELETE, 소급 무효화·보정** — 필요 사례 확인 시 보정 이벤트 방식으로 별도 설계.
|
||||||
- **sessions 판정 입력 컬럼(`target_anchoring_price`, `anchor_rate_permille`)의 사후 수정, `last_offered_price` 의 종료 후 수정** — 파생 판정의 결정성이 깨진다 (MUST NOT). 배치가 sessions에 쓸 수 있는 컬럼은 `anchoring_adjustment_id` 단 하나.
|
- **sessions 판정 입력 컬럼(`anchoring_price`, `anchoring_value`)의 사후 수정, `last_offer_price` 의 종료 후 수정** — 파생 판정의 결정성이 깨진다 (MUST NOT). 배치가 sessions에 쓸 수 있는 컬럼은 `used_by_adjustment_id` 단 하나.
|
||||||
- **파라미터 동적 조정** (δ, 경계 60/30, clamp 10/200, 임계 10건, 가격구간 사다리, 배치 주기, EVAL_WEEK_PARITY) — 전부 상수 고정.
|
- **파라미터 동적 조정** (δ, 경계 60/30, clamp 10/200, 임계 10건, 가격구간 사다리, 배치 주기, EVAL_WEEK_PARITY) — 전부 상수 고정.
|
||||||
- **성공률 외 신호 반영** (마진, 거래량, 시즌성 등) — 산식 입력은 파생 판정 결과뿐.
|
- **성공률 외 신호 반영** (마진, 거래량, 시즌성 등) — 산식 입력은 파생 판정 결과뿐.
|
||||||
- **회사 간 값·표본 공유 또는 전사 통합 평가** — 칸은 회사별 완전 독립.
|
- **회사 간 값·표본 공유 또는 전사 통합 평가** — 칸은 회사별 완전 독립.
|
||||||
@ -700,13 +707,13 @@ clamp·격리 케이스:
|
|||||||
|
|
||||||
| # | 항목 | 담당 | 상태 |
|
| # | 항목 | 담당 | 상태 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 1 | DDL — `anchoring.rate_adjustments` + sessions 3컬럼 ALTER | [우리 — 모듈] `schema.sql`, psql 적용 시점 협의 | 구현 완료 (§6) |
|
| 1 | DDL — `anchoring.adjustments` + sessions 3컬럼 ALTER | [우리 — 모듈] `postgres-init/05-anchoring-schema.sql`, psql 적용 시점 협의 | 구현 완료 (§6) |
|
||||||
| 2 | **세션 생성 시 앵커 산출을 새 시스템으로 교체** — `_build_quotation` 앵커 계산 교체 + 재생성 상속 폐지 | **[인수인계 — negodata]** | §9.1. reader 는 모듈(async)에서 그대로 이식 |
|
| 2 | **세션 생성 시 앵커 산출을 새 시스템으로 교체** — `_build_quotation` 앵커 계산 교체 + 재생성 상속 폐지 | **[인수인계 — negodata]** | §9.1. reader 는 모듈(async)에서 그대로 이식 |
|
||||||
| 3 | agent | **변경 없음** | 앵커 비노출 — 스크립트·프로토콜·엔진 무변경, 인수인계 항목 아님 |
|
| 3 | agent | **변경 없음** | 앵커 비노출 — 스크립트·프로토콜·엔진 무변경, 인수인계 항목 아님 |
|
||||||
| 4 | 재협상 식별 | — | `sessions.qt_type = 1` 로 판별 (확인됨) |
|
| 4 | 재협상 식별 | — | `sessions.qt_type = 1` 로 판별 (확인됨) |
|
||||||
| 5 | company_id 식별 | — | `partner.items.company_id` (세션→item 조인, 기존 `_agent_context` 해석 방식과 동일) |
|
| 5 | company_id 식별 | — | `partner.items.company_id` (세션→item 조인, 기존 `_agent_context` 해석 방식과 동일) |
|
||||||
| 6 | `quotations.supplier_type` 기록 | [인수인계 — negodata] | 재협상 견적 생성 시 채워져야 집계가 분류됨 (NULL 이면 안전 제외 — 마킹 0) |
|
| 6 | `quotations.supplier_type` 기록 | [인수인계 — negodata] | 재협상 견적 생성 시 채워져야 집계가 분류됨 (NULL 이면 안전 제외 — 마킹 0) |
|
||||||
| 7 | 정적 테이블 로드 검증 | [우리 — 모듈] | 기동 시 검증 실패 → 기동 중단 (MUST). 구현 완료 |
|
| 7 | 정적 테이블 로드 검증 | [우리 — 모듈] | 기동 시 검증 실패 → 기동 중단 (MUST). 구현 완료 |
|
||||||
| 8 | Redis 인프라 | [우리 — 모듈] | 모듈 docker-compose 에 redis 동봉, negodata 가 같은 인스턴스 참조. backend 는 Redis 무의존 |
|
| 8 | Redis 인프라 | [우리 — 모듈] | 루트 docker-compose 에 redis 동봉(모듈 전용 캐시). negodata 는 무Redis(뷰 직조회)·backend 도 무의존 |
|
||||||
| 9 | 스케줄러·배치 | [우리 — 모듈] | 자립 컨테이너(APScheduler, `--once` 수동 실행 지원). 구현 완료 |
|
| 9 | 스케줄러·배치 | [우리 — 모듈] | 자립 컨테이너(APScheduler, `--once` 수동 실행 지원). 구현 완료 |
|
||||||
| 10 | backend 채팅 수정 | [우리 — backend] | `_resolve_anchor_price` 박제값 소비 + NULL 폴백(목표가+WARN), 가격 입력 턴의 `last_offered_price` 갱신, sessions 모델 3컬럼, `quotation_settings.anchoring_value` 읽기 제거(컬럼은 유지). 구현 완료 |
|
| 10 | backend 채팅 수정 | [우리 — backend] | `_resolve_anchor_price` 박제값 소비 + NULL 폴백(목표가+WARN), 가격 입력 턴의 `last_offer_price` 갱신, sessions 모델 3컬럼, `quotation_settings.anchoring_value` 읽기 제거(컬럼은 유지). 구현 완료 |
|
||||||
|
|||||||
@ -32,7 +32,7 @@
|
|||||||
```
|
```
|
||||||
[anchoring 컨테이너] ──── 격주 배치 실행 (APScheduler 내장)
|
[anchoring 컨테이너] ──── 격주 배치 실행 (APScheduler 내장)
|
||||||
│ 읽기: negotiation.sessions / quotation.quotations / partner.items
|
│ 읽기: negotiation.sessions / quotation.quotations / partner.items
|
||||||
│ 쓰기: anchoring.rate_adjustments (조정 이력) + sessions.anchoring_adjustment_id (채점 마킹)
|
│ 쓰기: anchoring.adjustments (조정 이력) + sessions.used_by_adjustment_id (채점 마킹)
|
||||||
▼
|
▼
|
||||||
[PostgreSQL (외부, negosium_db)] [anchoring-redis 컨테이너]
|
[PostgreSQL (외부, negosium_db)] [anchoring-redis 컨테이너]
|
||||||
진실 원천 — 영구 이력 조회 캐시(사본) — 없어져도 복구됨
|
진실 원천 — 영구 이력 조회 캐시(사본) — 없어져도 복구됨
|
||||||
@ -54,27 +54,35 @@
|
|||||||
### STEP 1 — DB 스키마 적용 (최초 1회)
|
### STEP 1 — DB 스키마 적용 (최초 1회)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd schedules/anchoring
|
psql -h <DB호스트> -U <계정> -d negosium_db -f postgres-init/05-anchoring-schema.sql # 레포 루트에서
|
||||||
psql -h <DB호스트> -U <계정> -d negosium_db -f schema.sql
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- 테이블 1개(`anchoring.rate_adjustments`)·조회용 뷰 2개(`rate_history`, `current_rates`)와 `negotiation.sessions` 컬럼 3개를 추가합니다.
|
- 테이블 1개(`anchoring.adjustments`)·조회용 뷰 2개(`value_history`, `current_values`)·인덱스를 추가합니다.
|
||||||
|
(`negotiation.sessions` 앵커링 컬럼은 `postgres-init/01-schema*.sql`(신규)·`04-alter*.sql`(기존) 소관)
|
||||||
- `IF NOT EXISTS` 라 **여러 번 실행해도 안전**합니다.
|
- `IF NOT EXISTS` 라 **여러 번 실행해도 안전**합니다.
|
||||||
|
|
||||||
### STEP 2 — 설정 채우기
|
### STEP 2 — 설정 채우기
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp config.toml.example config.toml
|
# 실행 환경(APP_ENV, 기본 local)에 맞는 파일을 만든다 — local/dev/prod
|
||||||
# config.toml 열어서 [db] 호스트/계정/비밀번호 채우기
|
cp config.toml.example config.local.toml
|
||||||
|
# config.local.toml 열어서 [db] 호스트/계정/비밀번호 채우기 (dev/prod 는 config.dev.toml/config.prod.toml)
|
||||||
```
|
```
|
||||||
|
|
||||||
환경변수로 덮어쓸 수도 있습니다(우선순위: env > config.toml > 기본값):
|
- 환경 선택은 `APP_ENV` 환경변수(기본 `local` → `config.local.toml`). **dev/prod 는 파일이 없으면 기동이 즉시 중단**됩니다(오타·미배치 상태로 로컬 기본값에 붙는 사고 방지). local 은 파일 없이도 코드 기본값으로 뜹니다.
|
||||||
|
|
||||||
|
환경변수로 덮어쓸 수도 있습니다(우선순위: env > config.{APP_ENV}.toml > 기본값):
|
||||||
`DB_HOST` `DB_PORT` `DB_USER` `DB_PASSWORD` `DB_NAME` / `REDIS_HOST` `REDIS_PORT` `REDIS_DB` `REDIS_PASSWORD` / `LOG_LEVEL`
|
`DB_HOST` `DB_PORT` `DB_USER` `DB_PASSWORD` `DB_NAME` / `REDIS_HOST` `REDIS_PORT` `REDIS_DB` `REDIS_PASSWORD` / `LOG_LEVEL`
|
||||||
|
|
||||||
|
> config.{env}.toml 은 **이미지에 들어가지 않습니다**(시크릿이 이미지 레이어에 남는 것을 방지 — .dockerignore 로도 차단).
|
||||||
|
> 도커 실행 시 compose 가 읽기 전용 마운트하므로, **`docker compose up` 전에 해당 환경 파일이 반드시 존재해야 합니다**
|
||||||
|
> (없이 up 하면 docker 가 같은 이름의 디렉터리를 만들어 기동에 실패합니다). 루트 compose 는 APP_ENV=local + config.local.toml 마운트.
|
||||||
|
|
||||||
### STEP 3-A — 도커로 실행 (운영 권장)
|
### STEP 3-A — 도커로 실행 (운영 권장)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d --build
|
# 레포 루트에서 (anchoring 서비스는 루트 docker-compose.yml 에 통합됨)
|
||||||
|
docker compose up -d --build anchoring anchoring-redis
|
||||||
docker logs -f anchoring # 기동 로그 확인 (아래 §4)
|
docker logs -f anchoring # 기동 로그 확인 (아래 §4)
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -127,7 +135,7 @@ PYTHONPATH=src .venv/bin/python -m pytest tests/ -q # 전부 passed 기대(
|
|||||||
### 로그 한 줄의 구조
|
### 로그 한 줄의 구조
|
||||||
|
|
||||||
```
|
```
|
||||||
2026-07-02 16:45:12+0900 INFO anchoring [batch 20260702-164512] 조정 company=f23c… type=1 bracket=10 n=13 성공=8 10‰→30‰ adj_id=32
|
2026-07-02 16:45:12+0900 INFO anchoring [batch 20260702-164512] 조정 company=f23c… type=1 price_range=10 n=13 성공=8 10‰→30‰ adj_id=32
|
||||||
└──── 시각(항상 KST) ──┘ └레벨┘ └── 회차 태그 ──────┘ └──────────────── 내용 (key=value 형식) ────────────────┘
|
└──── 시각(항상 KST) ──┘ └레벨┘ └── 회차 태그 ──────┘ └──────────────── 내용 (key=value 형식) ────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -174,8 +182,8 @@ docker logs anchoring | tail -20 # 최근 상태
|
|||||||
| 수동 배치 1회 (격주 게이트 무시) | `docker exec anchoring python -m anchoring.main --once` |
|
| 수동 배치 1회 (격주 게이트 무시) | `docker exec anchoring python -m anchoring.main --once` |
|
||||||
| **예행 연습** (DB/Redis 무변경, 예상 결과만 로그) | `docker exec anchoring python -m anchoring.main --once --dry-run` — 첫 운영 실행 전 필수 권장 |
|
| **예행 연습** (DB/Redis 무변경, 예상 결과만 로그) | `docker exec anchoring python -m anchoring.main --once --dry-run` — 첫 운영 실행 전 필수 권장 |
|
||||||
| 재기동 | `docker compose restart anchoring` |
|
| 재기동 | `docker compose restart anchoring` |
|
||||||
| 서비스 중지/시작 | `docker compose stop` / `docker compose up -d` |
|
| 서비스 중지/시작 | `docker compose stop anchoring anchoring-redis` / `docker compose up -d anchoring anchoring-redis` (레포 루트에서) |
|
||||||
| 설정 변경 반영 | config.toml 수정 → `docker compose up -d --build` |
|
| 설정 변경 반영 | config.{env}.toml 수정 → `docker compose restart anchoring` (파일은 마운트라 리빌드 불필요) |
|
||||||
| 다음 실행 예정 시각 확인 | `docker logs anchoring \| grep "다음 실행 예정"` |
|
| 다음 실행 예정 시각 확인 | `docker logs anchoring \| grep "다음 실행 예정"` |
|
||||||
| 로그 레벨 올리기(디버깅) | env `LOG_LEVEL=debug` 로 재기동 |
|
| 로그 레벨 올리기(디버깅) | env `LOG_LEVEL=debug` 로 재기동 |
|
||||||
|
|
||||||
@ -184,11 +192,13 @@ docker logs anchoring | tail -20 # 최근 상태
|
|||||||
| 증상 (로그 메시지) | 원인 | 조치 |
|
| 증상 (로그 메시지) | 원인 | 조치 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 기동 실패 + `BaseTableError: 정적 테이블 …` | `resources/anchoring_base.json` 손상/수정됨 | **의도된 안전장치** — git 으로 파일 원복 후 재기동. 이 파일은 절대 수정 금지 |
|
| 기동 실패 + `BaseTableError: 정적 테이블 …` | `resources/anchoring_base.json` 손상/수정됨 | **의도된 안전장치** — git 으로 파일 원복 후 재기동. 이 파일은 절대 수정 금지 |
|
||||||
| 기동 실패 + DB 연결 예외 | config.toml/env 의 DB 접속 정보 오류 | 접속 정보 확인, `psql` 로 직접 접속 테스트 |
|
| 기동 실패 + DB 연결 예외 | config.{env}.toml/env 의 DB 접속 정보 오류(dev/prod 의 CHANGE_ME 미기입 포함) | 접속 정보 확인, `psql` 로 직접 접속 테스트 |
|
||||||
|
| 기동 실패 + `설정 파일이 없습니다 (APP_ENV=…)` | dev/prod 인데 config.{env}.toml 미배치 | `cp config.toml.example config.{env}.toml` 채우고 재기동 |
|
||||||
|
| 기동 실패 + `config.local.toml` 이 **디렉터리**로 생겨 있음 | 파일 없이 `docker compose up` — 마운트 대상이 없어 docker 가 디렉터리를 만듦 | `docker compose down` → 디렉터리 삭제 → `cp config.toml.example config.local.toml` 채우고 재기동 |
|
||||||
| `[redis] GET/SET 실패 … DB 폴백` WARN | Redis 다운/네트워크 | **서비스는 계속 정상 동작**(DB 폴백). `docker compose up -d anchoring-redis` 로 복구하면 다음 실행 때 캐시 자동 재적재 |
|
| `[redis] GET/SET 실패 … DB 폴백` WARN | Redis 다운/네트워크 | **서비스는 계속 정상 동작**(DB 폴백). `docker compose up -d anchoring-redis` 로 복구하면 다음 실행 때 캐시 자동 재적재 |
|
||||||
| `redis 실패 누계 get=… set=…` WARN | 위와 동일(회차 요약) | 위와 동일 |
|
| `redis 실패 누계 get=… set=…` WARN | 위와 동일(회차 요약) | 위와 동일 |
|
||||||
| `[redis] 범위 밖 캐시 값 무시(오염 의심)` WARN | 누군가/다른 프로세스가 Redis 에 비정상 값을 씀 | 동작엔 문제 없음(자동 무시 + DB 폴백 + 재적재로 자가 교정). 반복되면 Redis 접근 경로 점검 — 포트가 외부에 열려 있지 않은지(`127.0.0.1` 바인딩) 확인 |
|
| `[redis] 범위 밖 캐시 값 무시(오염 의심)` WARN | 누군가/다른 프로세스가 Redis 에 비정상 값을 씀 | 동작엔 문제 없음(자동 무시 + DB 폴백 + 재적재로 자가 교정). 반복되면 Redis 접근 경로 점검 — 포트가 외부에 열려 있지 않은지(`127.0.0.1` 바인딩) 확인 |
|
||||||
| `가격 제시 흔적 0%` WARN | backend 의 가격 기록 배선이 끊김(배포 사고 등) — 학습이 조용히 멈추는 신호 | backend 팀에 `chat_service` 의 `last_offered_price` 갱신 경로 점검 요청 |
|
| `가격 제시 흔적 0%` WARN | backend 의 가격 기록 배선이 끊김(배포 사고 등) — 학습이 조용히 멈추는 신호 | backend 팀에 `chat_service` 의 `last_offer_price` 갱신 경로 점검 요청 |
|
||||||
| `칸 평가 실패 company=…` ERROR | 해당 칸 DB 오류/마킹 경합 | 스택 확인. 실패 칸은 마킹되지 않아 **다음 회차 자동 재시도** — 같은 칸이 연속 실패하면 개발 팀 문의 |
|
| `칸 평가 실패 company=…` ERROR | 해당 칸 DB 오류/마킹 경합 | 스택 확인. 실패 칸은 마킹되지 않아 **다음 회차 자동 재시도** — 같은 칸이 연속 실패하면 개발 팀 문의 |
|
||||||
| `박제 정합 불일치 n건` WARN | negodata 의 앵커 산출 이식 오류 의심(정수식 ≠ 박제 anchor) | negodata 팀에 `docs/인수인계.md` §1.3 정수식 적용 여부 점검 요청 |
|
| `박제 정합 불일치 n건` WARN | negodata 의 앵커 산출 이식 오류 의심(정수식 ≠ 박제 anchor) | negodata 팀에 `docs/인수인계.md` §1.3 정수식 적용 여부 점검 요청 |
|
||||||
| 종료 요약이 WARNING (`failed_cells > 0`) | 일부 칸 실패 | 바로 위 ERROR 라인들 확인 |
|
| 종료 요약이 WARNING (`failed_cells > 0`) | 일부 칸 실패 | 바로 위 ERROR 라인들 확인 |
|
||||||
@ -201,35 +211,35 @@ docker logs anchoring | tail -20 # 최근 상태
|
|||||||
|
|
||||||
```sql
|
```sql
|
||||||
-- ① 어떤 회사의 값 변천사 (시간순) — 이전 값→새 값·변화폭·성공률까지 한 줄에
|
-- ① 어떤 회사의 값 변천사 (시간순) — 이전 값→새 값·변화폭·성공률까지 한 줄에
|
||||||
SELECT * FROM anchoring.rate_history
|
SELECT * FROM anchoring.value_history
|
||||||
WHERE company_id = '<uuid>'
|
WHERE company_id = '<uuid>'
|
||||||
ORDER BY adjustment_id;
|
ORDER BY adjustment_id;
|
||||||
|
|
||||||
-- ①-b 어떤 회사의 칸별 "현재값" 한눈에 (여기 없는 칸 = 시작값 1%)
|
-- ①-b 어떤 회사의 칸별 "현재값" 한눈에 (여기 없는 칸 = 시작값 1%)
|
||||||
SELECT * FROM anchoring.current_rates
|
SELECT * FROM anchoring.current_values
|
||||||
WHERE company_id = '<uuid>';
|
WHERE company_id = '<uuid>';
|
||||||
|
|
||||||
-- ② 특정 조정(adj_id)의 근거가 된 협상들
|
-- ② 특정 조정(adj_id)의 근거가 된 협상들
|
||||||
SELECT s.session_id, s.status, s.target_anchoring_price, s.last_offered_price, s.bid_price
|
SELECT s.session_id, s.status, s.anchoring_price, s.last_offer_price, s.bid_price
|
||||||
FROM negotiation.sessions s
|
FROM negotiation.sessions s
|
||||||
WHERE s.session_id IN (
|
WHERE s.session_id IN (
|
||||||
SELECT jsonb_array_elements_text(consumed_session_ids)::uuid
|
SELECT jsonb_array_elements_text(used_session_ids)::uuid
|
||||||
FROM anchoring.rate_adjustments WHERE id = <adj_id>
|
FROM anchoring.adjustments WHERE id = <adj_id>
|
||||||
);
|
);
|
||||||
|
|
||||||
-- ③ 특정 협상이 어느 조정에 채점됐나
|
-- ③ 특정 협상이 어느 조정에 채점됐나
|
||||||
SELECT anchoring_adjustment_id FROM negotiation.sessions WHERE session_id = '<uuid>';
|
SELECT used_by_adjustment_id FROM negotiation.sessions WHERE session_id = '<uuid>';
|
||||||
-- NULL = 아직 채점 전(다음 회차로 이월) / 0 = 채점 제외 확정 / 숫자 = 해당 조정 id → ② 로
|
-- NULL = 아직 채점 전(다음 회차로 이월) / 0 = 채점 제외 확정 / 숫자 = 해당 조정 id → ② 로
|
||||||
```
|
```
|
||||||
|
|
||||||
로그의 `adj_id=32` ↔ DB 의 `rate_adjustments.id=32` 가 같은 것을 가리킵니다.
|
로그의 `adj_id=32` ↔ DB 의 `adjustments.id=32` 가 같은 것을 가리킵니다.
|
||||||
|
|
||||||
## 9. 절대 하면 안 되는 것
|
## 9. 절대 하면 안 되는 것
|
||||||
|
|
||||||
이 시스템의 신뢰성은 "기록이 불변"이라는 전제 위에 서 있습니다 (상세 근거: `개발용.md` §12).
|
이 시스템의 신뢰성은 "기록이 불변"이라는 전제 위에 서 있습니다 (상세 근거: `개발용.md` §12).
|
||||||
|
|
||||||
- ❌ `anchoring.rate_adjustments` 행을 **UPDATE/DELETE** — 조정 이력은 유일한 진실 원천
|
- ❌ `anchoring.adjustments` 행을 **UPDATE/DELETE** — 조정 이력은 유일한 진실 원천
|
||||||
- ❌ `sessions` 의 `target_anchoring_price` / `anchor_rate_permille` 수동 수정 — 채점 근거가 오염됨
|
- ❌ `sessions` 의 `anchoring_price` / `anchoring_value` 수동 수정 — 채점 근거가 오염됨
|
||||||
- ❌ `resources/anchoring_base.json`(기준표) 수정 — 검증 실패로 기동이 막히며, 값 변경은 정책 재확정 사안
|
- ❌ `resources/anchoring_base.json`(기준표) 수정 — 검증 실패로 기동이 막히며, 값 변경은 정책 재확정 사안
|
||||||
- ❌ 상수(조정폭 δ, 경계 60/30, 상·하한, 10건 임계, 배치 주기) 임의 변경 — 전부 정책 고정값
|
- ❌ 상수(조정폭 δ, 경계 60/30, 상·하한, 10건 임계, 배치 주기) 임의 변경 — 전부 정책 고정값
|
||||||
- ❌ anchoring 컨테이너를 **2개 이상 동시 실행** — 중복 조정 방지 장치(롤백)가 막아주긴 하지만 설계상 단일 인스턴스가 원칙
|
- ❌ anchoring 컨테이너를 **2개 이상 동시 실행** — 중복 조정 방지 장치(롤백)가 막아주긴 하지만 설계상 단일 인스턴스가 원칙
|
||||||
@ -246,4 +256,4 @@ docker logs anchoring | grep "다음 실행 예정" # ③ (재기
|
|||||||
|
|
||||||
- ① 이 비어 있고 ② 가 `'status': 'done'` 이면 끝.
|
- ① 이 비어 있고 ② 가 `'status': 'done'` 이면 끝.
|
||||||
- `carryover_cells`(이월)가 계속 크기만 하고 `evaluated_cells` 가 0인 상태가 몇 달 지속되면 거래량 자체가 적은 것 — 장애가 아니라 정책 검토(희소 칸 과제, `기획용.md` FAQ) 대상입니다.
|
- `carryover_cells`(이월)가 계속 크기만 하고 `evaluated_cells` 가 0인 상태가 몇 달 지속되면 거래량 자체가 적은 것 — 장애가 아니라 정책 검토(희소 칸 과제, `기획용.md` FAQ) 대상입니다.
|
||||||
- 분기에 한 번쯤: 조정 이력 백업이 DB 백업 정책에 포함돼 있는지 확인 (`rate_adjustments` 는 영구 보존 대상).
|
- 분기에 한 번쯤: 조정 이력 백업이 DB 백업 정책에 포함돼 있는지 확인 (`adjustments` 는 영구 보존 대상).
|
||||||
|
|||||||
@ -17,7 +17,7 @@
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **기준표** (정적 기본 테이블) | 공장 출하 시 기본 설정값 | 모든 칸의 출발점(전부 1%). 절대 안 바뀌는 내장 표 |
|
| **기준표** (정적 기본 테이블) | 공장 출하 시 기본 설정값 | 모든 칸의 출발점(전부 1%). 절대 안 바뀌는 내장 표 |
|
||||||
| **협상 기록** (`negotiation.sessions`) | 협상 한 건 한 건의 계약서 철 | "그때 기준가가 얼마였고, 상대가 얼마를 써냈고, 얼마에 끝났는지"가 적힘 |
|
| **협상 기록** (`negotiation.sessions`) | 협상 한 건 한 건의 계약서 철 | "그때 기준가가 얼마였고, 상대가 얼마를 써냈고, 얼마에 끝났는지"가 적힘 |
|
||||||
| **조정 장부** (`anchoring.rate_adjustments`) | 가격 정책 변경 대장 | "언제, 어떤 근거로, 몇 %에서 몇 %로 바꿨다"가 한 줄씩만 추가됨 |
|
| **조정 장부** (`anchoring.adjustments`) | 가격 정책 변경 대장 | "언제, 어떤 근거로, 몇 %에서 몇 %로 바꿨다"가 한 줄씩만 추가됨 |
|
||||||
| **빠른 조회판** (Redis) | 벽에 붙여둔 최신 가격표 | 협상 시작할 때 즉시 참조하는 사본. 원본은 항상 조정 장부 |
|
| **빠른 조회판** (Redis) | 벽에 붙여둔 최신 가격표 | 협상 시작할 때 즉시 참조하는 사본. 원본은 항상 조정 장부 |
|
||||||
|
|
||||||
여기서 **칸(cell)** 이란 값을 관리하는 최소 단위로, **어느 회사 × 어떤 협력사 유형(유통/제조/총판) × 어떤 가격대** 조합입니다. 가격대는 자릿수 단위 사다리(1천 원대·2천 원대 … 1만 원대·2만 원대 … 9천만 원대, 총 46칸)로 나뉘고, 1억을 넘는 금액은 전부 마지막 가격대 칸으로 들어갑니다.
|
여기서 **칸(cell)** 이란 값을 관리하는 최소 단위로, **어느 회사 × 어떤 협력사 유형(유통/제조/총판) × 어떤 가격대** 조합입니다. 가격대는 자릿수 단위 사다리(1천 원대·2천 원대 … 1만 원대·2만 원대 … 9천만 원대, 총 46칸)로 나뉘고, 1억을 넘는 금액은 전부 마지막 가격대 칸으로 들어갑니다.
|
||||||
@ -44,7 +44,7 @@
|
|||||||
|
|
||||||
### ③ 협력사가 가격을 써낼 때 — "가격 흔적"
|
### ③ 협력사가 가격을 써낼 때 — "가격 흔적"
|
||||||
|
|
||||||
협력사가 협상 채팅에서 가격을 입력할 때마다, 그 **마지막 제시가가 협상 기록에 남습니다**(`last_offered_price`).
|
협력사가 협상 채팅에서 가격을 입력할 때마다, 그 **마지막 제시가가 협상 기록에 남습니다**(`last_offer_price`).
|
||||||
|
|
||||||
이게 중요한 이유: **가격을 써낸** 협상과 **한 번도 안 써낸** 협상은 정책적으로 완전히 다르게 취급하기 때문입니다.
|
이게 중요한 이유: **가격을 써낸** 협상과 **한 번도 안 써낸** 협상은 정책적으로 완전히 다르게 취급하기 때문입니다.
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
## 배경 한 줄
|
## 배경 한 줄
|
||||||
|
|
||||||
앵커링 값(목표가에서 깎는 비율)이 고정 설정(`quotation_settings.anchoring_value`)에서 **칸(회사 × 협력사유형 × 가격구간)별 자동 조정 값**으로 바뀐다. 값의 원천은 `anchoring.rate_adjustments`(조정 이력) + Redis 캐시이며, **`schedules/anchoring` 자립 서비스**(독립 컨테이너)의 격주 배치가 협상 결과로 값을 조정한다. backend 는 협상 채팅에서 박제값을 소비할 뿐 앵커링 모듈에 의존하지 않는다.
|
앵커링 값(목표가에서 깎는 비율)이 고정 설정(`quotation_settings.anchoring_value`)에서 **칸(회사 × 협력사유형 × 가격구간)별 자동 조정 값**으로 바뀐다. 값의 원천은 `anchoring.adjustments`(조정 이력) + Redis 캐시이며, **`schedules/anchoring` 자립 서비스**(독립 컨테이너)의 격주 배치가 협상 결과로 값을 조정한다. backend 는 협상 채팅에서 박제값을 소비할 뿐 앵커링 모듈에 의존하지 않는다.
|
||||||
|
|
||||||
## 전달물 (→ 각 담당자)
|
## 전달물 (→ 각 담당자)
|
||||||
|
|
||||||
@ -14,14 +14,21 @@
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `schedules/anchoring/src/anchoring/` 모듈 | `constants.py`(상수·enum) · `base_table.py`(정적 테이블 로더) · `service.py`(순수 계산 함수) · `redis_client.py` · `reader.py`(rate 조회) — **전부 async(SQLAlchemy async + redis.asyncio) 자립형이라 negodata 에 그대로 복사/이식 가능** |
|
| `schedules/anchoring/src/anchoring/` 모듈 | `constants.py`(상수·enum) · `base_table.py`(정적 테이블 로더) · `service.py`(순수 계산 함수) · `redis_client.py` · `reader.py`(rate 조회) — **전부 async(SQLAlchemy async + redis.asyncio) 자립형이라 negodata 에 그대로 복사/이식 가능** |
|
||||||
| `schedules/anchoring/src/anchoring/resources/anchoring_base.json` | 정적 기본 테이블 (46행 자릿수 사다리, 불변) |
|
| `schedules/anchoring/src/anchoring/resources/anchoring_base.json` | 정적 기본 테이블 (46행 자릿수 사다리, 불변) |
|
||||||
| `schedules/anchoring/schema.sql` | `anchoring.rate_adjustments` 테이블 + `negotiation.sessions` 컬럼 3개 ALTER — 모듈 소유 DDL, psql 수동 적용 (적용 시점 협의) |
|
| `postgres-init/05-anchoring-schema.sql` | `anchoring.adjustments` 테이블·뷰·인덱스 — 모듈 소유 DDL, psql 수동 적용 (sessions 컬럼은 01/04 소관) |
|
||||||
| `schedules/anchoring/docker-compose.yml` | anchoring 서비스 + redis 동봉 — **negodata 는 이 redis 인스턴스를 바라본다** (`REDIS_HOST` 환경변수) |
|
| 루트 `docker-compose.yml` | anchoring 서비스 + redis 동봉(모듈 전용 캐시) — **negodata 는 Redis 를 쓰지 않는다**(`current_values` 뷰 직조회) |
|
||||||
| 이 문서 | 적용 위치·변경 전후 명세 |
|
| 이 문서 | 적용 위치·변경 전후 명세 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. negodata 변경 (견적 생성 측)
|
## 1. negodata 변경 (견적 생성 측)
|
||||||
|
|
||||||
|
> ✅ **적용 완료 (2026-07-04)** — negodata 담당자 승인 하에 backend 담당(민헌)이 이 절을 직접 적용했다.
|
||||||
|
>
|
||||||
|
> - 이식 위치: `negodata/backend/common/anchoring/` (constants·base_table·service 는 읽기 경로 발췌, reader 는 이식판) + `_build_quotation` 앵커 산출 교체 + 재생성 앵커 상속 폐지 + `sessions.anchoring_value` 모델 매핑
|
||||||
|
> - **이식판 reader 는 Redis 캐시를 쓰지 않는다**: `anchoring.current_values` 뷰 단일 쿼리 → 실패·무이력 시 정적 테이블 폴백. (2026-07-03 단순화 결정 — 조회가 견적 생성 시 1회뿐이라 캐시 불필요. 모듈 쪽 Redis 제거는 후속 백로그로 진행)
|
||||||
|
> - 검증: negodata 테스트 스위트 50종 통과 — 앵커링 신설 5종(스키마 부재 폴백·칸별 조정 반영·유형 미지정 폴백·재생성 앵커 재계산·`calc_price_range_index` 경계 골든 벡터) 포함
|
||||||
|
> - 전환기 점프 확인(§3-③): 적용 시점 `adjustments` 0건 → 점프 없음(시작값 10‰ = 구 기본 `anchoring_value` 0.01 과 동일)
|
||||||
|
|
||||||
### 1.1 변경 대상
|
### 1.1 변경 대상
|
||||||
|
|
||||||
`negodata/backend/services/quotation_service.py` — `_build_quotation()` 의 세션 생성 루프(현재 448~470행 부근)와 `regenerate` 경로의 상속 로직(현재 319행 부근).
|
`negodata/backend/services/quotation_service.py` — `_build_quotation()` 의 세션 생성 루프(현재 448~470행 부근)와 `regenerate` 경로의 상속 로직(현재 319행 부근).
|
||||||
@ -47,15 +54,15 @@ else:
|
|||||||
# ① 칸 해석
|
# ① 칸 해석
|
||||||
# company_id = items.company_id (해당 상품의 소유 회사)
|
# company_id = items.company_id (해당 상품의 소유 회사)
|
||||||
# supplier_type = quotations.supplier_type (이번 견적의 유형 코드 1/2/3)
|
# supplier_type = quotations.supplier_type (이번 견적의 유형 코드 1/2/3)
|
||||||
# bracket = calc_bracket_index(tp) # 자릿수 사다리(46칸) — service 모듈 함수 그대로 이식
|
# price_range = calc_price_range_index(tp) # 자릿수 사다리(46칸) — service 모듈 함수 그대로 이식
|
||||||
# ② rate 조회 — 전달받은 reader 모듈 사용
|
# ② rate 조회 — 전달받은 reader 모듈 사용
|
||||||
rate = await get_anchor_rate(db, company_id, supplier_type, bracket) # db = AsyncSession
|
value = await get_current_anchoring_value(db, company_id, supplier_type, price_range) # db = AsyncSession
|
||||||
# 내부 동작: Redis GET → miss 시 anchoring.rate_adjustments 최신 행 → 없으면 정적 테이블(10‰)
|
# 내부 동작: Redis GET → miss 시 anchoring.adjustments 최신 행 → 없으면 정적 테이블(10‰)
|
||||||
# supplier_type ∉ {1,2,3} 이면 get_base_rate_permille(bracket) 사용 (정적 테이블 시작값)
|
# supplier_type ∉ {1,2,3} 이면 get_base_anchoring_value(price_range) 사용 (정적 테이블 시작값)
|
||||||
# ③ 앵커링가 — 정수 연산만 (float 곱셈 금지: int(tp * 0.99) 형태 재사용 불가)
|
# ③ 앵커링가 — 정수 연산만 (float 곱셈 금지: int(tp * 0.99) 형태 재사용 불가)
|
||||||
ap = tp * (1000 - rate) // 1000
|
ap = tp * (1000 - rate) // 1000
|
||||||
# ④ 세션 INSERT 에 두 컬럼 모두 박제
|
# ④ 세션 INSERT 에 두 컬럼 모두 박제
|
||||||
sessions(..., target_anchoring_price=ap, anchor_rate_permille=rate, ...)
|
sessions(..., anchoring_price=ap, anchoring_value=rate, ...)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 1.4 필수 규칙
|
### 1.4 필수 규칙
|
||||||
@ -64,7 +71,7 @@ sessions(..., target_anchoring_price=ap, anchor_rate_permille=rate, ...)
|
|||||||
2. **정수 연산 MUST**: `tp * (1000 - rate) // 1000`. 부동소수점 곱셈(`int(tp * (1 - x))`, `round(...)`) 금지 — 1원 단위 내림의 정확성 보장.
|
2. **정수 연산 MUST**: `tp * (1000 - rate) // 1000`. 부동소수점 곱셈(`int(tp * (1 - x))`, `round(...)`) 금지 — 1원 단위 내림의 정확성 보장.
|
||||||
3. **`quotation_settings.anchoring_value` 는 앵커가 계산에 더 이상 사용하지 않는다.** 컬럼 자체와 산정내역 화면 표기는 유지해도 된다(표시 정리는 선택).
|
3. **`quotation_settings.anchoring_value` 는 앵커가 계산에 더 이상 사용하지 않는다.** 컬럼 자체와 산정내역 화면 표기는 유지해도 된다(표시 정리는 선택).
|
||||||
4. **`quotations.supplier_type` 기록 유지**: 재협상 견적 생성 시 이 값이 채워져야 앵커링 집계가 유형별로 분류된다(NULL 이면 해당 세션은 학습에서 자동 제외).
|
4. **`quotations.supplier_type` 기록 유지**: 재협상 견적 생성 시 이 값이 채워져야 앵커링 집계가 유형별로 분류된다(NULL 이면 해당 세션은 학습에서 자동 제외).
|
||||||
5. **박제 후 수정 금지**: `sessions.target_anchoring_price` / `anchor_rate_permille` 는 생성 시 1회 기록 후 절대 UPDATE 하지 않는다 — 협상 결과 판정의 기준값이므로 사후 수정 시 학습 데이터가 오염된다.
|
5. **박제 후 수정 금지**: `sessions.anchoring_price` / `anchoring_value` 는 생성 시 1회 기록 후 절대 UPDATE 하지 않는다 — 협상 결과 판정의 기준값이므로 사후 수정 시 학습 데이터가 오염된다.
|
||||||
6. **Redis 장애 내성**: reader 는 Redis 불능 시 자동으로 DB → 정적 테이블 순으로 폴백한다(예외를 밖으로 던지지 않음). 견적 생성이 Redis 때문에 실패하면 안 된다.
|
6. **Redis 장애 내성**: reader 는 Redis 불능 시 자동으로 DB → 정적 테이블 순으로 폴백한다(예외를 밖으로 던지지 않음). 견적 생성이 Redis 때문에 실패하면 안 된다.
|
||||||
|
|
||||||
### 1.5 적용 전(전환기) 동작
|
### 1.5 적용 전(전환기) 동작
|
||||||
@ -75,18 +82,18 @@ sessions(..., target_anchoring_price=ap, anchor_rate_permille=rate, ...)
|
|||||||
|
|
||||||
## 2. agent — **변경 없음**
|
## 2. agent — **변경 없음**
|
||||||
|
|
||||||
앵커링가는 협력사에게 표시하지 않는 **비노출 전략**으로 확정됐다(v1.2 개정 3 — 정보 비대칭 유지, 상대 선제안 유도). 앵커는 지금처럼 chat 엔진의 내부 체결 임계(`check_price_match` 등)로만 동작하며, **스크립트·프로토콜·엔진 어느 것도 수정할 필요가 없다.** 표본 판정에 필요한 "협력사 마지막 제시가" 기록은 backend 가 담당한다(`sessions.last_offered_price`).
|
앵커링가는 협력사에게 표시하지 않는 **비노출 전략**으로 확정됐다(v1.2 개정 3 — 정보 비대칭 유지, 상대 선제안 유도). 앵커는 지금처럼 chat 엔진의 내부 체결 임계(`check_price_match` 등)로만 동작하며, **스크립트·프로토콜·엔진 어느 것도 수정할 필요가 없다.** 표본 판정에 필요한 "협력사 마지막 제시가" 기록은 backend 가 담당한다(`sessions.last_offer_price`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. 적용 순서 (권장)
|
## 3. 적용 순서 (권장)
|
||||||
|
|
||||||
```
|
```
|
||||||
① DB 스키마 적용 (schedules/anchoring/schema.sql — rate_adjustments + sessions 컬럼 3개)
|
① DB 스키마 적용 (postgres-init/05-anchoring-schema.sql — adjustments·뷰·인덱스, sessions 컬럼은 01/04)
|
||||||
② anchoring 서비스 기동 (schedules/anchoring 컨테이너 — 격주 배치·Redis 캐시 시작)
|
② anchoring 서비스 기동 (schedules/anchoring 컨테이너 — 격주 배치·Redis 캐시 시작)
|
||||||
+ backend 배포 (마지막 제시가 기록·박제값 소비 — 이 시점부터 표본·조정이 쌓이기 시작)
|
+ backend 배포 (마지막 제시가 기록·박제값 소비 — 이 시점부터 표본·조정이 쌓이기 시작)
|
||||||
③ 전환기 점프 확인 (negodata 적용 직전):
|
③ 전환기 점프 확인 (negodata 적용 직전):
|
||||||
SELECT max(anchor_rate_after) FROM anchoring.current_rates;
|
SELECT max(anchoring_value) FROM anchoring.current_values;
|
||||||
— ②~③ 사이에 학습이 진행되므로, 적용 순간 앵커가 학습된 rate 로 한 번에 이동한다
|
— ②~③ 사이에 학습이 진행되므로, 적용 순간 앵커가 학습된 rate 로 한 번에 이동한다
|
||||||
("조정일당 한 계단" 원칙이 이 순간만 예외). 값이 크게 벌어져 있으면 점프 감수 여부
|
("조정일당 한 계단" 원칙이 이 순간만 예외). 값이 크게 벌어져 있으면 점프 감수 여부
|
||||||
또는 이력 리셋을 정책 결정 후 진행.
|
또는 이력 리셋을 정책 결정 후 진행.
|
||||||
|
|||||||
112
schedules/anchoring/migrations/20260706_rename_anchoring.sql
Normal file
112
schedules/anchoring/migrations/20260706_rename_anchoring.sql
Normal 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;
|
||||||
@ -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;
|
|
||||||
@ -6,11 +6,11 @@ DB 에 저장하지 않으며 런타임에 절대 수정하지 않는다. 검증
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from anchoring.constants import BRACKET_COUNT, UPPER_BOUNDS
|
from anchoring.constants import PRICE_RANGE_COUNT, UPPER_BOUNDS
|
||||||
|
|
||||||
_RESOURCE = Path(__file__).parent / "resources" / "anchoring_base.json"
|
_RESOURCE = Path(__file__).parent / "resources" / "anchoring_base.json"
|
||||||
|
|
||||||
_rates: list[int] | None = None # bracket_index → 시작값(‰)
|
_values: list[int] | None = None # price_range_index → 시작값(‰)
|
||||||
|
|
||||||
|
|
||||||
class BaseTableError(RuntimeError):
|
class BaseTableError(RuntimeError):
|
||||||
@ -22,9 +22,9 @@ def _validate(rows: list) -> list[int]:
|
|||||||
|
|
||||||
규약(§2.1): 46행 · idx 1..46 연속 · upper_bound == 사다리(UPPER_BOUNDS) · 값 0.01~0.20.
|
규약(§2.1): 46행 · idx 1..46 연속 · upper_bound == 사다리(UPPER_BOUNDS) · 값 0.01~0.20.
|
||||||
"""
|
"""
|
||||||
if not isinstance(rows, list) or len(rows) != BRACKET_COUNT:
|
if not isinstance(rows, list) or len(rows) != PRICE_RANGE_COUNT:
|
||||||
raise BaseTableError(f"정적 테이블 행 수 불일치: {len(rows) if isinstance(rows, list) else type(rows)} != {BRACKET_COUNT}")
|
raise BaseTableError(f"정적 테이블 행 수 불일치: {len(rows) if isinstance(rows, list) else type(rows)} != {PRICE_RANGE_COUNT}")
|
||||||
rates: list[int] = []
|
values: list[int] = []
|
||||||
for i, row in enumerate(rows):
|
for i, row in enumerate(rows):
|
||||||
idx = row.get("idx")
|
idx = row.get("idx")
|
||||||
ub = row.get("upper_bound")
|
ub = row.get("upper_bound")
|
||||||
@ -35,24 +35,24 @@ def _validate(rows: list) -> list[int]:
|
|||||||
raise BaseTableError(f"upper_bound 사다리 불일치: idx={idx} upper_bound={ub} (기대 {UPPER_BOUNDS[i]})")
|
raise BaseTableError(f"upper_bound 사다리 불일치: idx={idx} upper_bound={ub} (기대 {UPPER_BOUNDS[i]})")
|
||||||
if not isinstance(av, (int, float)) or av != av or not (0.01 <= av <= 0.20):
|
if not isinstance(av, (int, float)) or av != av or not (0.01 <= av <= 0.20):
|
||||||
raise BaseTableError(f"anchoring_value 범위 밖: idx={idx} value={av}")
|
raise BaseTableError(f"anchoring_value 범위 밖: idx={idx} value={av}")
|
||||||
rates.append(int(round(av * 1000)))
|
values.append(int(round(av * 1000)))
|
||||||
return rates
|
return values
|
||||||
|
|
||||||
|
|
||||||
def load_base_table() -> None:
|
def load_base_table() -> None:
|
||||||
"""리소스 파일 로드 + 검증. 기동 시 1회 호출(멱등)."""
|
"""리소스 파일 로드 + 검증. 기동 시 1회 호출(멱등)."""
|
||||||
global _rates
|
global _values
|
||||||
if _rates is not None:
|
if _values is not None:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
rows = json.loads(_RESOURCE.read_text())
|
rows = json.loads(_RESOURCE.read_text())
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
raise BaseTableError(f"정적 테이블 파일 로드 실패: {_RESOURCE}: {ex}") from ex
|
raise BaseTableError(f"정적 테이블 파일 로드 실패: {_RESOURCE}: {ex}") from ex
|
||||||
_rates = _validate(rows)
|
_values = _validate(rows)
|
||||||
|
|
||||||
|
|
||||||
def get_base_rate_permille(bracket_index: int) -> int:
|
def get_base_anchoring_value(price_range_index: int) -> int:
|
||||||
"""구간 인덱스 → 시작 앵커링 값(‰). §2.1"""
|
"""구간 인덱스 → 시작 앵커링 값(‰). §2.1"""
|
||||||
if _rates is None:
|
if _values is None:
|
||||||
load_base_table()
|
load_base_table()
|
||||||
return _rates[bracket_index]
|
return _values[price_range_index]
|
||||||
|
|||||||
@ -11,10 +11,8 @@ from zoneinfo import ZoneInfo
|
|||||||
|
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import select, update
|
||||||
|
|
||||||
from anchoring.base_table import get_base_rate_permille
|
from anchoring.base_table import get_base_anchoring_value
|
||||||
from anchoring.constants import (
|
from anchoring.constants import (
|
||||||
ANCHOR_RATE_MAX,
|
|
||||||
ANCHOR_RATE_MIN,
|
|
||||||
EVAL_WEEK_PARITY,
|
EVAL_WEEK_PARITY,
|
||||||
MARK_EXCLUDED,
|
MARK_EXCLUDED,
|
||||||
QT_TYPE_RENEGO,
|
QT_TYPE_RENEGO,
|
||||||
@ -26,10 +24,10 @@ from anchoring.constants import (
|
|||||||
)
|
)
|
||||||
from anchoring.db import session_scope
|
from anchoring.db import session_scope
|
||||||
from anchoring.log import LOG
|
from anchoring.log import LOG
|
||||||
from anchoring.models import Item, Quotation, RateAdjustment, Session
|
from anchoring.models import Item, Quotation, Adjustment, Session
|
||||||
from anchoring.reader import get_latest_adjusted_rate
|
from anchoring.reader import get_latest_adjusted_value
|
||||||
from anchoring.redis_client import consume_failure_counts, ping, set_rate
|
from anchoring.redis_client import consume_failure_counts, ping, set_value
|
||||||
from anchoring.service import calc_bracket_index, evaluate_pending, judge_sample_type
|
from anchoring.service import calc_anchoring_price, calc_price_range_index, evaluate_samples, judge_sample_type
|
||||||
|
|
||||||
KST = ZoneInfo("Asia/Seoul")
|
KST = ZoneInfo("Asia/Seoul")
|
||||||
_MARK_CHUNK = 1000
|
_MARK_CHUNK = 1000
|
||||||
@ -45,34 +43,34 @@ def is_evaluation_week(now_kst: datetime) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
async def _reconcile_cache() -> int:
|
async def _reconcile_cache() -> int:
|
||||||
"""절차 0.5 — 조정 이력 보유 칸 전체의 최신 rate 를 Redis 일괄 re-SET.
|
"""절차 0.5 — 조정 이력 보유 칸 전체의 최신 anchoring_value 를 Redis 일괄 re-SET.
|
||||||
|
|
||||||
stale 키는 미스가 나지 않으므로(TTL 전까지) 매주 이걸로 회복한다(§7).
|
stale 키는 미스가 나지 않으므로(TTL 전까지) 매주 이걸로 회복한다(§7).
|
||||||
"""
|
"""
|
||||||
stmt = (
|
stmt = (
|
||||||
select(
|
select(
|
||||||
RateAdjustment.company_id,
|
Adjustment.company_id,
|
||||||
RateAdjustment.supplier_type,
|
Adjustment.supplier_type,
|
||||||
RateAdjustment.price_bracket_index,
|
Adjustment.price_range_index,
|
||||||
RateAdjustment.anchor_rate_after,
|
Adjustment.anchoring_value_after,
|
||||||
)
|
)
|
||||||
.distinct(
|
.distinct(
|
||||||
RateAdjustment.company_id,
|
Adjustment.company_id,
|
||||||
RateAdjustment.supplier_type,
|
Adjustment.supplier_type,
|
||||||
RateAdjustment.price_bracket_index,
|
Adjustment.price_range_index,
|
||||||
)
|
)
|
||||||
.order_by(
|
.order_by(
|
||||||
RateAdjustment.company_id,
|
Adjustment.company_id,
|
||||||
RateAdjustment.supplier_type,
|
Adjustment.supplier_type,
|
||||||
RateAdjustment.price_bracket_index,
|
Adjustment.price_range_index,
|
||||||
RateAdjustment.id.desc(),
|
Adjustment.adjustment_id.desc(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
async with session_scope() as db:
|
async with session_scope() as db:
|
||||||
rows = (await db.execute(stmt)).all()
|
rows = (await db.execute(stmt)).all()
|
||||||
ok = 0
|
ok = 0
|
||||||
for company_id, stype, bracket, rate in rows:
|
for company_id, stype, price_range, value in rows:
|
||||||
if await set_rate(company_id, stype, bracket, rate):
|
if await set_value(company_id, stype, price_range, value):
|
||||||
ok += 1
|
ok += 1
|
||||||
return ok
|
return ok
|
||||||
|
|
||||||
@ -90,9 +88,9 @@ async def _scan_pending(db, company_ids: list | None = None) -> list:
|
|||||||
Session.status,
|
Session.status,
|
||||||
Session.bid_price,
|
Session.bid_price,
|
||||||
Session.target_price,
|
Session.target_price,
|
||||||
Session.target_anchoring_price,
|
Session.anchoring_price,
|
||||||
Session.anchor_rate_permille,
|
Session.anchoring_value,
|
||||||
Session.last_offered_price,
|
Session.last_offer_price,
|
||||||
Quotation.supplier_type,
|
Quotation.supplier_type,
|
||||||
Item.company_id,
|
Item.company_id,
|
||||||
)
|
)
|
||||||
@ -107,7 +105,7 @@ async def _scan_pending(db, company_ids: list | None = None) -> list:
|
|||||||
isouter=True,
|
isouter=True,
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
Session.anchoring_adjustment_id.is_(None),
|
Session.used_by_adjustment_id.is_(None),
|
||||||
Session.deleted.is_(False),
|
Session.deleted.is_(False),
|
||||||
Session.qt_type == QT_TYPE_RENEGO,
|
Session.qt_type == QT_TYPE_RENEGO,
|
||||||
Session.status.in_(TERMINAL_SESSION_STATUSES),
|
Session.status.in_(TERMINAL_SESSION_STATUSES),
|
||||||
@ -125,15 +123,15 @@ async def _mark_sessions(db, session_ids: list, adjustment_id: int) -> int:
|
|||||||
chunk = session_ids[i:i + _MARK_CHUNK]
|
chunk = session_ids[i:i + _MARK_CHUNK]
|
||||||
res = await db.execute(
|
res = await db.execute(
|
||||||
update(Session)
|
update(Session)
|
||||||
.where(Session.session_id.in_(chunk), Session.anchoring_adjustment_id.is_(None))
|
.where(Session.session_id.in_(chunk), Session.used_by_adjustment_id.is_(None))
|
||||||
.values(anchoring_adjustment_id=adjustment_id)
|
.values(used_by_adjustment_id=adjustment_id)
|
||||||
.execution_options(synchronize_session=False)
|
.execution_options(synchronize_session=False)
|
||||||
)
|
)
|
||||||
marked += res.rowcount
|
marked += res.rowcount
|
||||||
return marked
|
return marked
|
||||||
|
|
||||||
|
|
||||||
async def _evaluate_cell(company_id, supplier_type: int, bracket: int, samples: list,
|
async def _evaluate_cell(company_id, supplier_type: int, price_range: int, samples: list,
|
||||||
dry_run: bool = False) -> dict | None:
|
dry_run: bool = False) -> dict | None:
|
||||||
"""칸 1개 평가 — 조정 INSERT + 소비 마킹을 같은 세션 한 트랜잭션으로(§8 MUST).
|
"""칸 1개 평가 — 조정 INSERT + 소비 마킹을 같은 세션 한 트랜잭션으로(§8 MUST).
|
||||||
|
|
||||||
@ -145,47 +143,50 @@ async def _evaluate_cell(company_id, supplier_type: int, bracket: int, samples:
|
|||||||
sample_types = [st for _, st in samples]
|
sample_types = [st for _, st in samples]
|
||||||
|
|
||||||
async with session_scope() as db:
|
async with session_scope() as db:
|
||||||
latest = await get_latest_adjusted_rate(db, company_id, supplier_type, bracket)
|
latest = await get_latest_adjusted_value(db, company_id, supplier_type, price_range)
|
||||||
rate_before = latest if latest is not None else get_base_rate_permille(bracket)
|
value_before = latest if latest is not None else get_base_anchoring_value(price_range)
|
||||||
rate_after = evaluate_pending(rate_before, sample_types, supplier_type)
|
evaluated = evaluate_samples(value_before, sample_types, supplier_type)
|
||||||
if rate_after is None: # 방어적 재확인(호출측에서 n>=10 보장)
|
if evaluated is None: # 방어적 재확인(호출측에서 n>=10 보장)
|
||||||
return None
|
return None
|
||||||
|
value_after, clamped = evaluated
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
return {
|
return {
|
||||||
"adjustment_id": None,
|
"adjustment_id": None,
|
||||||
"before": rate_before,
|
"before": value_before,
|
||||||
"after": rate_after,
|
"after": value_after,
|
||||||
|
"clamped": clamped,
|
||||||
"success": sum(1 for st in sample_types if st == AnchoringSampleType.BID_SUCCESS.value),
|
"success": sum(1 for st in sample_types if st == AnchoringSampleType.BID_SUCCESS.value),
|
||||||
"n": len(samples),
|
"n": len(samples),
|
||||||
}
|
}
|
||||||
|
|
||||||
adjustment = RateAdjustment(
|
adjustment = Adjustment(
|
||||||
company_id=company_id,
|
company_id=company_id,
|
||||||
supplier_type=supplier_type,
|
supplier_type=supplier_type,
|
||||||
price_bracket_index=bracket,
|
price_range_index=price_range,
|
||||||
nego_count=len(samples),
|
sample_count=len(samples),
|
||||||
success_count=sum(1 for st in sample_types if st == AnchoringSampleType.BID_SUCCESS.value),
|
success_count=sum(1 for st in sample_types if st == AnchoringSampleType.BID_SUCCESS.value),
|
||||||
anchor_rate_before=rate_before,
|
anchoring_value_before=value_before,
|
||||||
anchor_rate_after=rate_after,
|
anchoring_value_after=value_after,
|
||||||
consumed_session_ids=[str(sid) for sid in session_ids],
|
used_session_ids=[str(sid) for sid in session_ids],
|
||||||
)
|
)
|
||||||
db.add(adjustment)
|
db.add(adjustment)
|
||||||
await db.flush() # adjustment.id 확보
|
await db.flush() # adjustment_id 확보
|
||||||
|
|
||||||
marked = await _mark_sessions(db, session_ids, adjustment.id)
|
marked = await _mark_sessions(db, session_ids, adjustment.adjustment_id)
|
||||||
if marked != len(session_ids):
|
if marked != len(session_ids):
|
||||||
# 다른 실행이 먼저 소비함(오설정으로 배치 중복 등) → 조정 INSERT 포함 전체 롤백
|
# 다른 실행이 먼저 소비함(오설정으로 배치 중복 등) → 조정 INSERT 포함 전체 롤백
|
||||||
raise MarkingConflictError(
|
raise MarkingConflictError(
|
||||||
f"company={company_id} type={supplier_type} bracket={bracket} 마킹 {marked}/{len(session_ids)}"
|
f"company={company_id} type={supplier_type} price_range={price_range} 마킹 {marked}/{len(session_ids)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 커밋 후에만 캐시 반영(best effort — 실패는 TTL·주간 re-SET 이 회복)
|
# 커밋 후에만 캐시 반영(best effort — 실패는 TTL·주간 re-SET 이 회복)
|
||||||
await set_rate(company_id, supplier_type, bracket, rate_after)
|
await set_value(company_id, supplier_type, price_range, value_after)
|
||||||
return {
|
return {
|
||||||
"adjustment_id": adjustment.id,
|
"adjustment_id": adjustment.adjustment_id,
|
||||||
"before": rate_before,
|
"before": value_before,
|
||||||
"after": rate_after,
|
"after": value_after,
|
||||||
|
"clamped": clamped,
|
||||||
"success": adjustment.success_count,
|
"success": adjustment.success_count,
|
||||||
"n": len(samples),
|
"n": len(samples),
|
||||||
}
|
}
|
||||||
@ -205,7 +206,7 @@ async def run_evaluation_batch(force: bool = False, company_ids: list | None = N
|
|||||||
첫 운영 실행 전 "이번 회차에 무슨 일이 일어날지" 확인용(§8 런북).
|
첫 운영 실행 전 "이번 회차에 무슨 일이 일어날지" 확인용(§8 런북).
|
||||||
|
|
||||||
로그 규약: 모든 라인에 `[batch {run_id}]` 태그(회차 grep), 칸/회사 단위 라인은
|
로그 규약: 모든 라인에 `[batch {run_id}]` 태그(회차 grep), 칸/회사 단위 라인은
|
||||||
`company=` `type=` `bracket=` key=value 형식(회사별 grep — `grep company=<uuid>`).
|
`company=` `type=` `price_range=` key=value 형식(회사별 grep — `grep company=<uuid>`).
|
||||||
"""
|
"""
|
||||||
now = datetime.now(KST)
|
now = datetime.now(KST)
|
||||||
run_id = now.strftime("%Y%m%d-%H%M%S")
|
run_id = now.strftime("%Y%m%d-%H%M%S")
|
||||||
@ -240,14 +241,14 @@ async def run_evaluation_batch(force: bool = False, company_ids: list | None = N
|
|||||||
priced = 0
|
priced = 0
|
||||||
snapshot_mismatch = []
|
snapshot_mismatch = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
if r.last_offered_price is not None:
|
if r.last_offer_price is not None:
|
||||||
priced += 1
|
priced += 1
|
||||||
# 박제 정합 감시: negodata 가 rate 와 anchor 를 함께 박제하기 시작하면(인수인계 적용 후)
|
# 박제 정합 감시: negodata 가 anchoring_value 와 anchoring_price 를 함께 박제하기 시작하면(인수인계 적용 후)
|
||||||
# 정수식 tp*(1000-rate)//1000 과 박제 anchor 가 일치해야 한다 — 불일치 = 이식 오류 신호.
|
# 정수식 tp*(1000-value)//1000 과 박제 anchoring_price 가 일치해야 한다 — 불일치 = 이식 오류 신호.
|
||||||
# 전환기(rate 미박제 = NULL)에는 자동 스킵된다.
|
# 전환기(value 미박제 = NULL)에는 자동 스킵된다.
|
||||||
if (r.anchor_rate_permille is not None and r.target_anchoring_price is not None
|
if (r.anchoring_value is not None and r.anchoring_price is not None
|
||||||
and r.target_price is not None
|
and r.target_price is not None
|
||||||
and r.target_price * (1000 - r.anchor_rate_permille) // 1000 != r.target_anchoring_price):
|
and calc_anchoring_price(r.target_price, r.anchoring_value) != r.anchoring_price):
|
||||||
snapshot_mismatch.append(r.session_id)
|
snapshot_mismatch.append(r.session_id)
|
||||||
if r.supplier_type not in SAMPLEABLE_SUPPLIER_TYPES or r.company_id is None:
|
if r.supplier_type not in SAMPLEABLE_SUPPLIER_TYPES or r.company_id is None:
|
||||||
excluded_ids.append(r.session_id) # 칸 구성 불가
|
excluded_ids.append(r.session_id) # 칸 구성 불가
|
||||||
@ -257,24 +258,24 @@ async def run_evaluation_batch(force: bool = False, company_ids: list | None = N
|
|||||||
sample_type = judge_sample_type(
|
sample_type = judge_sample_type(
|
||||||
is_done=r.status == SESSION_STATUS_DONE,
|
is_done=r.status == SESSION_STATUS_DONE,
|
||||||
bid_price=r.bid_price,
|
bid_price=r.bid_price,
|
||||||
last_offered_price=r.last_offered_price,
|
last_offer_price=r.last_offer_price,
|
||||||
anchor_price=r.target_anchoring_price,
|
anchoring_price=r.anchoring_price,
|
||||||
)
|
)
|
||||||
if sample_type == AnchoringSampleType.EXCLUDED.value:
|
if sample_type == AnchoringSampleType.EXCLUDED.value:
|
||||||
excluded_ids.append(r.session_id)
|
excluded_ids.append(r.session_id)
|
||||||
per_company[str(r.company_id)]["excluded"] += 1
|
per_company[str(r.company_id)]["excluded"] += 1
|
||||||
continue
|
continue
|
||||||
bracket = calc_bracket_index(r.target_price)
|
price_range = calc_price_range_index(r.target_price)
|
||||||
cells[(r.company_id, r.supplier_type, bracket)].append((r.session_id, sample_type))
|
cells[(r.company_id, r.supplier_type, price_range)].append((r.session_id, sample_type))
|
||||||
|
|
||||||
if snapshot_mismatch:
|
if snapshot_mismatch:
|
||||||
sample = ", ".join(str(sid) for sid in snapshot_mismatch[:5])
|
sample = ", ".join(str(sid) for sid in snapshot_mismatch[:5])
|
||||||
LOG.warning(f"{tag} 박제 정합 불일치 {len(snapshot_mismatch)}건 — negodata 앵커 산출 이식 오류 의심 "
|
LOG.warning(f"{tag} 박제 정합 불일치 {len(snapshot_mismatch)}건 — negodata 앵커 산출 이식 오류 의심 "
|
||||||
f"(정수식과 박제 anchor 불일치). 예: {sample}")
|
f"(정수식과 박제 anchor 불일치). 예: {sample}")
|
||||||
|
|
||||||
# 가격 제시율 — backend 의 last_offered_price 기록 배선 유실(무증상 학습 동결) 감지(§8 절차 5)
|
# 가격 제시율 — backend 의 last_offer_price 기록 배선 유실(무증상 학습 동결) 감지(§8 절차 5)
|
||||||
if rows and priced == 0:
|
if rows and priced == 0:
|
||||||
LOG.warning(f"{tag} 가격 제시 흔적 0% (종료 재협상 {len(rows)}건 중 last_offered_price 전무) "
|
LOG.warning(f"{tag} 가격 제시 흔적 0% (종료 재협상 {len(rows)}건 중 last_offer_price 전무) "
|
||||||
f"— backend 가격 입력 기록 배선 점검 필요")
|
f"— backend 가격 입력 기록 배선 점검 필요")
|
||||||
|
|
||||||
# 절차 2 — 제외 확정 마킹(재스캔 방지). 청크별 개별 커밋 — 판정이 결정적이라
|
# 절차 2 — 제외 확정 마킹(재스캔 방지). 청크별 개별 커밋 — 판정이 결정적이라
|
||||||
@ -293,18 +294,18 @@ async def run_evaluation_batch(force: bool = False, company_ids: list | None = N
|
|||||||
# 절차 3~4 — 칸별 평가(칸 단위 독립 트랜잭션 — 한 칸 실패가 전파되지 않음)
|
# 절차 3~4 — 칸별 평가(칸 단위 독립 트랜잭션 — 한 칸 실패가 전파되지 않음)
|
||||||
evaluated = up = hold = down = clamped = failed = 0
|
evaluated = up = hold = down = clamped = failed = 0
|
||||||
carryover = 0
|
carryover = 0
|
||||||
for (company_id, stype, bracket), samples in cells.items():
|
for (company_id, stype, price_range), samples in cells.items():
|
||||||
agg = per_company[str(company_id)]
|
agg = per_company[str(company_id)]
|
||||||
if len(samples) < SAMPLE_THRESHOLD:
|
if len(samples) < SAMPLE_THRESHOLD:
|
||||||
carryover += 1 # 마킹하지 않음 = 이월(§4.4)
|
carryover += 1 # 마킹하지 않음 = 이월(§4.4)
|
||||||
agg["carryover"] += 1
|
agg["carryover"] += 1
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
result = await _evaluate_cell(company_id, stype, bracket, samples, dry_run=dry_run)
|
result = await _evaluate_cell(company_id, stype, price_range, samples, dry_run=dry_run)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
failed += 1
|
failed += 1
|
||||||
agg["failed"] += 1
|
agg["failed"] += 1
|
||||||
LOG.error(f"{tag} 칸 평가 실패 company={company_id} type={stype} bracket={bracket}: {ex}", exc_info=True)
|
LOG.error(f"{tag} 칸 평가 실패 company={company_id} type={stype} price_range={price_range}: {ex}", exc_info=True)
|
||||||
continue
|
continue
|
||||||
if result is None:
|
if result is None:
|
||||||
carryover += 1
|
carryover += 1
|
||||||
@ -314,7 +315,7 @@ async def run_evaluation_batch(force: bool = False, company_ids: list | None = N
|
|||||||
agg["evaluated"] += 1
|
agg["evaluated"] += 1
|
||||||
# 칸별 조정 상세 — 로그만으로 "어느 칸이 왜 바뀌었나" 추적 + DB(adj_id) 교차 확인
|
# 칸별 조정 상세 — 로그만으로 "어느 칸이 왜 바뀌었나" 추적 + DB(adj_id) 교차 확인
|
||||||
label = "조정예정" if dry_run else "조정"
|
label = "조정예정" if dry_run else "조정"
|
||||||
LOG.info(f"{tag} {label} company={company_id} type={stype} bracket={bracket} "
|
LOG.info(f"{tag} {label} company={company_id} type={stype} price_range={price_range} "
|
||||||
f"n={result['n']} 성공={result['success']} {result['before']}‰→{result['after']}‰ "
|
f"n={result['n']} 성공={result['success']} {result['before']}‰→{result['after']}‰ "
|
||||||
f"adj_id={result['adjustment_id']}")
|
f"adj_id={result['adjustment_id']}")
|
||||||
if result["after"] > result["before"]:
|
if result["after"] > result["before"]:
|
||||||
@ -326,7 +327,7 @@ async def run_evaluation_batch(force: bool = False, company_ids: list | None = N
|
|||||||
else:
|
else:
|
||||||
hold += 1
|
hold += 1
|
||||||
agg["hold"] += 1
|
agg["hold"] += 1
|
||||||
if result["after"] in (ANCHOR_RATE_MIN, ANCHOR_RATE_MAX):
|
if result["clamped"]:
|
||||||
clamped += 1
|
clamped += 1
|
||||||
|
|
||||||
# 회사별 요약 — 멀티테넌트 운영에서 테넌트 단위 상태를 한 줄로
|
# 회사별 요약 — 멀티테넌트 운영에서 테넌트 단위 상태를 한 줄로
|
||||||
|
|||||||
@ -1,13 +1,17 @@
|
|||||||
"""설정 — config.toml + env 오버라이드(env > toml > 기본값).
|
"""설정 — config.{APP_ENV}.toml + env 오버라이드(env > toml > 기본값).
|
||||||
|
|
||||||
자립 모듈: backend config 체계를 쓰지 않는다. 시크릿은 config.toml(.gitignore) 또는 env 로.
|
자립 모듈: backend config 체계를 쓰지 않지만 선택 방식은 동일 — 환경변수 APP_ENV(기본 local)로
|
||||||
|
config.local.toml / config.dev.toml / config.prod.toml 을 고른다(전부 .gitignore, 시크릿은 파일 또는 env 로).
|
||||||
|
local 은 파일이 없어도 코드 기본값으로 뜨지만(개발 편의), 그 외 환경은 파일이 없으면
|
||||||
|
즉시 중단한다 — 오타/미배치 상태로 기본값(로컬 DB)에 붙는 사고 방지.
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import tomllib
|
import tomllib
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
_CONFIG_PATH = Path(__file__).resolve().parents[2] / "config.toml"
|
APP_ENV = os.environ.get("APP_ENV", "local")
|
||||||
|
_CONFIG_PATH = Path(__file__).resolve().parents[2] / f"config.{APP_ENV}.toml"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -45,6 +49,11 @@ def _env(name: str, current, cast=str):
|
|||||||
|
|
||||||
def load_config() -> Config:
|
def load_config() -> Config:
|
||||||
cfg = Config()
|
cfg = Config()
|
||||||
|
if not _CONFIG_PATH.exists() and APP_ENV != "local":
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"설정 파일이 없습니다: {_CONFIG_PATH} (APP_ENV={APP_ENV}). "
|
||||||
|
f"config.toml.example 을 복사해 채우거나 APP_ENV=local 로 실행하세요."
|
||||||
|
)
|
||||||
if _CONFIG_PATH.exists():
|
if _CONFIG_PATH.exists():
|
||||||
data = tomllib.loads(_CONFIG_PATH.read_text())
|
data = tomllib.loads(_CONFIG_PATH.read_text())
|
||||||
db = data.get("db", {})
|
db = data.get("db", {})
|
||||||
|
|||||||
@ -7,13 +7,13 @@ backend 를 import 하지 않고 자체 보유한다(자립 모듈). 코드값
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
# ── 앵커링 값(정수 천분율 ‰) ──────────────────────────────
|
# ── 앵커링 값(정수 천분율 ‰) ──────────────────────────────
|
||||||
ANCHOR_RATE_MIN = 10 # 하한 1%
|
ANCHORING_VALUE_MIN = 10 # 하한 1%
|
||||||
ANCHOR_RATE_MAX = 200 # 상한 20%
|
ANCHORING_VALUE_MAX = 200 # 상한 20%
|
||||||
# 시작값은 상수가 아니라 정적 테이블(base_table)에서 로드 — 0.01/10 하드코딩 금지(§2)
|
# 시작값은 상수가 아니라 정적 테이블(base_table)에서 로드 — 0.01/10 하드코딩 금지(§2)
|
||||||
|
|
||||||
# 유형별 조정폭 (올림·내림 대칭). 키 = quotations.supplier_type SMALLINT 코드
|
# 유형별 조정폭 (올림·내림 대칭). 키 = quotations.supplier_type SMALLINT 코드
|
||||||
# ⚠️ 스왑 주의: 2=제조=±1%, 3=총판=±1.5% (v1.1 ENUM명 기준 표와 코드 순서가 다름)
|
# ⚠️ 스왑 주의: 2=제조=±1%, 3=총판=±1.5% (v1.1 ENUM명 기준 표와 코드 순서가 다름)
|
||||||
DELTA_PERMILLE = {
|
ADJUSTMENT_STEP = {
|
||||||
1: 20, # 유통(DISTRIBUTION) ±2%
|
1: 20, # 유통(DISTRIBUTION) ±2%
|
||||||
2: 10, # 제조(MANUFACTURE) ±1%
|
2: 10, # 제조(MANUFACTURE) ±1%
|
||||||
3: 15, # 총판(SOLE_AGENCY/WHOLESALE) ±1.5%
|
3: 15, # 총판(SOLE_AGENCY/WHOLESALE) ±1.5%
|
||||||
@ -36,13 +36,13 @@ def _build_upper_bounds() -> tuple:
|
|||||||
|
|
||||||
|
|
||||||
UPPER_BOUNDS = _build_upper_bounds() # 46개 — 구간 = [이전 upper_bound, upper_bound) 좌폐우개
|
UPPER_BOUNDS = _build_upper_bounds() # 46개 — 구간 = [이전 upper_bound, upper_bound) 좌폐우개
|
||||||
BRACKET_COUNT = len(UPPER_BOUNDS) # 46
|
PRICE_RANGE_COUNT = len(UPPER_BOUNDS) # 46
|
||||||
BRACKET_INDEX_MAX = BRACKET_COUNT - 1 # 45
|
PRICE_RANGE_INDEX_MAX = PRICE_RANGE_COUNT - 1 # 45
|
||||||
|
|
||||||
# ── 배치 ──────────────────────────────────────────────────
|
# ── 배치 ──────────────────────────────────────────────────
|
||||||
EVAL_WEEK_PARITY = 0 # ISO 주차 % 2 == 0 인 토요일만 평가 (기준 고정. ISO 53주 해에
|
EVAL_WEEK_PARITY = 0 # ISO 주차 % 2 == 0 인 토요일만 평가 (기준 고정. ISO 53주 해에
|
||||||
# 같은 패리티 토요일이 연속될 수 있으나 누적 평가라 자가 치유)
|
# 같은 패리티 토요일이 연속될 수 있으나 누적 평가라 자가 치유)
|
||||||
MARK_EXCLUDED = 0 # sessions.anchoring_adjustment_id 제외 확정 마킹값 (BIGSERIAL 은 1부터라 충돌 없음)
|
MARK_EXCLUDED = 0 # sessions.used_by_adjustment_id 제외 확정 마킹값 (BIGSERIAL 은 1부터라 충돌 없음)
|
||||||
|
|
||||||
# ── Redis 캐시 (§7) ──────────────────────────────────────
|
# ── Redis 캐시 (§7) ──────────────────────────────────────
|
||||||
CACHE_TTL_SECONDS = 7 * 24 * 3600 # stale 잔존 방지 보조(주 방어선은 주간 re-SET)
|
CACHE_TTL_SECONDS = 7 * 24 * 3600 # stale 잔존 방지 보조(주 방어선은 주간 re-SET)
|
||||||
@ -50,7 +50,7 @@ REDIS_SOCKET_TIMEOUT = 0.3 # 행(hang) 방지 — 초과 시 DB 폴백
|
|||||||
|
|
||||||
|
|
||||||
class SupplierType(Enum):
|
class SupplierType(Enum):
|
||||||
"""협력사 유형 코드. quotation.quotations.supplier_type / anchoring.rate_adjustments.supplier_type
|
"""협력사 유형 코드. quotation.quotations.supplier_type / anchoring.adjustments.supplier_type
|
||||||
(negodata SupplierType 과 동일 코드)"""
|
(negodata SupplierType 과 동일 코드)"""
|
||||||
NONE = 0 # 미지정 — 앵커링 칸 구성 불가(집계 제외)
|
NONE = 0 # 미지정 — 앵커링 칸 구성 불가(집계 제외)
|
||||||
DISTRIBUTION = 1 # 유통
|
DISTRIBUTION = 1 # 유통
|
||||||
|
|||||||
@ -9,34 +9,51 @@
|
|||||||
|
|
||||||
기동 시 정적 테이블 검증 실패 → 예외로 즉시 중단(§13-7 MUST).
|
기동 시 정적 테이블 검증 실패 → 예외로 즉시 중단(§13-7 MUST).
|
||||||
"""
|
"""
|
||||||
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from anchoring.base_table import load_base_table
|
from anchoring.base_table import load_base_table
|
||||||
from anchoring.batch import run_evaluation_batch
|
from anchoring.batch import run_evaluation_batch
|
||||||
from anchoring.config import load_config
|
from anchoring.config import APP_ENV, load_config
|
||||||
from anchoring.constants import BRACKET_COUNT
|
from anchoring.constants import PRICE_RANGE_COUNT
|
||||||
from anchoring.db import dispose_engine, init_engine
|
from anchoring.db import dispose_engine, init_engine
|
||||||
from anchoring.log import LOG, configure
|
from anchoring.log import LOG, configure
|
||||||
from anchoring.redis_client import close_redis, init_redis
|
from anchoring.redis_client import close_redis, init_redis
|
||||||
from anchoring.scheduler import build_scheduler
|
from anchoring.scheduler import build_scheduler
|
||||||
|
|
||||||
|
|
||||||
async def _run(once: bool) -> None:
|
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="python -m anchoring.main",
|
||||||
|
description="앵커링 격주 조정 배치 — 기본은 스케줄러 상주",
|
||||||
|
)
|
||||||
|
parser.add_argument("--once", action="store_true",
|
||||||
|
help="수동 1회 실행(격주 게이트 무시) 후 종료")
|
||||||
|
parser.add_argument("--dry-run", action="store_true",
|
||||||
|
help="판정·예상 조정을 로그로만 — DB/Redis 무변경 (--once 필수)")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
# 상주 모드에는 dry-run 이 없다 — 무시된 채 실제 변경 스케줄러가 뜨는 사고를 기동 전에 차단
|
||||||
|
if args.dry_run and not args.once:
|
||||||
|
parser.error("--dry-run 은 --once 와 함께만 쓸 수 있습니다")
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
async def _run(once: bool, dry_run: bool = False) -> None:
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
configure(cfg.log_level)
|
configure(cfg.log_level)
|
||||||
|
|
||||||
|
LOG.info(f"[main] 설정 로드 — APP_ENV={APP_ENV} (config.{APP_ENV}.toml, env 오버라이드 우선)")
|
||||||
load_base_table() # 검증 실패 시 BaseTableError → 기동 중단
|
load_base_table() # 검증 실패 시 BaseTableError → 기동 중단
|
||||||
LOG.info(f"[main] 정적 기본 테이블 로드·검증 완료 ({BRACKET_COUNT}칸 사다리)")
|
LOG.info(f"[main] 정적 기본 테이블 로드·검증 완료 ({PRICE_RANGE_COUNT}칸 사다리)")
|
||||||
init_engine(cfg)
|
init_engine(cfg)
|
||||||
init_redis(cfg.redis)
|
init_redis(cfg.redis)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if once:
|
if once:
|
||||||
dry = "--dry-run" in sys.argv
|
LOG.info(f"[main] 수동 1회 실행(--once, 격주 게이트 무시{', dry-run' if dry_run else ''})")
|
||||||
LOG.info(f"[main] 수동 1회 실행(--once, 격주 게이트 무시{', dry-run' if dry else ''})")
|
result = await run_evaluation_batch(force=True, dry_run=dry_run)
|
||||||
result = await run_evaluation_batch(force=True, dry_run=dry)
|
|
||||||
LOG.info(f"[main] 결과: {result}")
|
LOG.info(f"[main] 결과: {result}")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@ -60,7 +77,8 @@ async def _run(once: bool) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
result = asyncio.run(_run(once="--once" in sys.argv))
|
args = _parse_args()
|
||||||
|
result = asyncio.run(_run(once=args.once, dry_run=args.dry_run))
|
||||||
# --once 가 부분 실패(partial)로 끝나면 비정상 종료코드 — 런북/cron 에서 감지 가능해야 한다
|
# --once 가 부분 실패(partial)로 끝나면 비정상 종료코드 — 런북/cron 에서 감지 가능해야 한다
|
||||||
if result is not None and result.get("status") not in ("done", "skipped", "dry_run"):
|
if result is not None and result.get("status") not in ("done", "skipped", "dry_run"):
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"""ORM 모델 — 자립(backend models 미사용).
|
"""ORM 모델 — 자립(backend models 미사용).
|
||||||
|
|
||||||
- 소유(쓰기): anchoring.rate_adjustments (append-only — UPDATE/DELETE 금지 §5)
|
- 소유(쓰기): anchoring.adjustments (append-only — UPDATE/DELETE 금지 §5)
|
||||||
- sessions 는 anchoring_adjustment_id 마킹만 쓰기 가능(그 외 컬럼 수정 금지 §12).
|
- sessions 는 used_by_adjustment_id 마킹만 쓰기 가능(그 외 컬럼 수정 금지 §12).
|
||||||
quotations/items 는 읽기 전용 경량 매핑(집계에 필요한 컬럼만).
|
quotations/items 는 읽기 전용 경량 매핑(집계에 필요한 컬럼만).
|
||||||
"""
|
"""
|
||||||
from sqlalchemy import BigInteger, Boolean, Column, DateTime, Integer, SmallInteger, text
|
from sqlalchemy import BigInteger, Boolean, Column, DateTime, Integer, SmallInteger, text
|
||||||
@ -11,19 +11,19 @@ from sqlalchemy.orm import declarative_base
|
|||||||
BASE = declarative_base()
|
BASE = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
class RateAdjustment(BASE):
|
class Adjustment(BASE):
|
||||||
__tablename__ = "rate_adjustments"
|
__tablename__ = "adjustments"
|
||||||
__table_args__ = {"schema": "anchoring"}
|
__table_args__ = {"schema": "anchoring"}
|
||||||
|
|
||||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
adjustment_id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
company_id = Column(UUID(as_uuid=True), nullable=False)
|
company_id = Column(UUID(as_uuid=True), nullable=False)
|
||||||
supplier_type = Column(SmallInteger, nullable=False) # 1유통/2제조/3총판
|
supplier_type = Column(SmallInteger, nullable=False) # 1유통/2제조/3총판
|
||||||
price_bracket_index = Column(Integer, nullable=False) # 0..45 (자릿수 사다리)
|
price_range_index = Column(Integer, nullable=False) # 0..45 (자릿수 사다리)
|
||||||
nego_count = Column(Integer, nullable=False) # 유효 표본 수 n
|
sample_count = Column(Integer, nullable=False) # 유효 표본 수 n
|
||||||
success_count = Column(Integer, nullable=False)
|
success_count = Column(Integer, nullable=False)
|
||||||
anchor_rate_before = Column(SmallInteger, nullable=False) # ‰
|
anchoring_value_before = Column(SmallInteger, nullable=False) # ‰
|
||||||
anchor_rate_after = Column(SmallInteger, nullable=False) # ‰, clamp [10,200]
|
anchoring_value_after = Column(SmallInteger, nullable=False) # ‰, clamp [10,200]
|
||||||
consumed_session_ids = Column(JSONB, nullable=False) # 소비 세션 uuid 문자열 배열(창 박제)
|
used_session_ids = Column(JSONB, nullable=False) # 소비 세션 uuid 문자열 배열(창 박제)
|
||||||
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("now()"))
|
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("now()"))
|
||||||
|
|
||||||
|
|
||||||
@ -37,10 +37,10 @@ class Session(BASE):
|
|||||||
item_id = Column(UUID(as_uuid=True), nullable=False)
|
item_id = Column(UUID(as_uuid=True), nullable=False)
|
||||||
qt_type = Column(SmallInteger, nullable=False) # 1=재협상
|
qt_type = Column(SmallInteger, nullable=False) # 1=재협상
|
||||||
target_price = Column(BigInteger, nullable=False)
|
target_price = Column(BigInteger, nullable=False)
|
||||||
target_anchoring_price = Column(BigInteger, nullable=True) # 박제 앵커가(판정 기준)
|
anchoring_price = Column(BigInteger, nullable=True) # 박제 앵커가(판정 기준)
|
||||||
anchor_rate_permille = Column(SmallInteger, nullable=True) # 박제 rate
|
anchoring_value = Column(SmallInteger, nullable=True) # 박제 anchoring_value(‰)
|
||||||
last_offered_price = Column(BigInteger, nullable=True) # 마지막 제시가(가격 흔적 — NULL=표본 제외)
|
last_offer_price = Column(BigInteger, nullable=True) # 마지막 제시가(가격 흔적 — NULL=표본 제외)
|
||||||
anchoring_adjustment_id = Column(BigInteger, nullable=True) # 소비 마킹(모듈이 쓰는 유일 컬럼)
|
used_by_adjustment_id = Column(BigInteger, nullable=True) # 소비 마킹(모듈이 쓰는 유일 컬럼)
|
||||||
status = Column(SmallInteger, nullable=False) # 3=DONE 4=NOT_PARTICIPATED 5=REJECTED
|
status = Column(SmallInteger, nullable=False) # 3=DONE 4=NOT_PARTICIPATED 5=REJECTED
|
||||||
bid_price = Column(BigInteger, nullable=True)
|
bid_price = Column(BigInteger, nullable=True)
|
||||||
deleted = Column(Boolean, nullable=False)
|
deleted = Column(Boolean, nullable=False)
|
||||||
|
|||||||
@ -1,42 +1,42 @@
|
|||||||
"""현재 앵커링 값 조회(읽기 경로). 규범: §4.5, §7, §9.1.
|
"""현재 앵커링 값 조회(읽기 경로). 규범: §4.5, §7, §9.1.
|
||||||
|
|
||||||
negodata 이식 대상 — 견적/세션 생성 시 이 함수로 칸 rate 를 얻어
|
negodata 이식 대상 — 견적/세션 생성 시 이 함수로 칸 anchoring_value 를 얻어
|
||||||
anchor_price = target_price * (1000 - rate) // 1000 를 정수 연산으로 계산·박제한다.
|
anchoring_price = target_price * (1000 - value) // 1000 를 정수 연산으로 계산·박제한다.
|
||||||
|
|
||||||
순서: Redis GET → miss: 조정 이력 최신 행 → 없으면 정적 테이블 시작값 → Redis SET(best effort).
|
순서: Redis GET → miss: 조정 이력 최신 행 → 없으면 정적 테이블 시작값 → Redis SET(best effort).
|
||||||
supplier_type ∉ {1,2,3} 인 경우 호출하지 말고 get_base_rate_permille(bracket) 을 직접 쓴다(§9.1).
|
supplier_type ∉ {1,2,3} 인 경우 호출하지 말고 get_base_anchoring_value(price_range_index) 를 직접 쓴다(§9.1).
|
||||||
"""
|
"""
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from anchoring.base_table import get_base_rate_permille
|
from anchoring.base_table import get_base_anchoring_value
|
||||||
from anchoring.models import RateAdjustment
|
from anchoring.models import Adjustment
|
||||||
from anchoring.redis_client import get_rate, set_rate
|
from anchoring.redis_client import get_value, set_value
|
||||||
|
|
||||||
|
|
||||||
async def get_latest_adjusted_rate(db: AsyncSession, company_id, supplier_type: int, bracket_index: int) -> int | None:
|
async def get_latest_adjusted_value(db: AsyncSession, company_id, supplier_type: int, price_range_index: int) -> int | None:
|
||||||
"""칸의 최신 조정 행 rate_after. 이력 없으면 None."""
|
"""칸의 최신 조정 행 value_after. 이력 없으면 None."""
|
||||||
stmt = (
|
stmt = (
|
||||||
select(RateAdjustment.anchor_rate_after)
|
select(Adjustment.anchoring_value_after)
|
||||||
.where(
|
.where(
|
||||||
RateAdjustment.company_id == company_id,
|
Adjustment.company_id == company_id,
|
||||||
RateAdjustment.supplier_type == supplier_type,
|
Adjustment.supplier_type == supplier_type,
|
||||||
RateAdjustment.price_bracket_index == bracket_index,
|
Adjustment.price_range_index == price_range_index,
|
||||||
)
|
)
|
||||||
.order_by(RateAdjustment.id.desc())
|
.order_by(Adjustment.adjustment_id.desc())
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
return (await db.execute(stmt)).scalar_one_or_none()
|
return (await db.execute(stmt)).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
async def get_anchor_rate(db: AsyncSession, company_id, supplier_type: int, bracket_index: int) -> int:
|
async def get_current_anchoring_value(db: AsyncSession, company_id, supplier_type: int, price_range_index: int) -> int:
|
||||||
"""칸의 현재 앵커링 값(‰). Redis → 조정 이력 → 정적 테이블 → SET."""
|
"""칸의 현재 앵커링 값(‰). Redis → 조정 이력 → 정적 테이블 → SET."""
|
||||||
cached = await get_rate(company_id, supplier_type, bracket_index)
|
cached = await get_value(company_id, supplier_type, price_range_index)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
return cached
|
return cached
|
||||||
|
|
||||||
rate = await get_latest_adjusted_rate(db, company_id, supplier_type, bracket_index)
|
value = await get_latest_adjusted_value(db, company_id, supplier_type, price_range_index)
|
||||||
if rate is None:
|
if value is None:
|
||||||
rate = get_base_rate_permille(bracket_index)
|
value = get_base_anchoring_value(price_range_index)
|
||||||
await set_rate(company_id, supplier_type, bracket_index, rate, nx=True)
|
await set_value(company_id, supplier_type, price_range_index, value, nx=True)
|
||||||
return rate
|
return value
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
"""Redis 캐시 클라이언트. 규범: §7.
|
"""Redis 캐시 클라이언트. 규범: §7.
|
||||||
|
|
||||||
- 키: anchor:{company_id}:{supplier_type}:{bracket_index} (supplier_type 은 SMALLINT 코드값)
|
- 키: anchor:{company_id}:{supplier_type}:{price_range_index} (supplier_type 은 SMALLINT 코드값)
|
||||||
- 값: 정수 천분율 문자열, TTL 7일(주 방어선은 배치의 주간 re-SET)
|
- 값: 정수 천분율 문자열, TTL 7일(주 방어선은 배치의 주간 re-SET)
|
||||||
- 장애 내성 MUST: 에러 시 GET→None(DB 폴백), SET→로그만. Redis 가 견적/배치를 막으면 안 된다.
|
- 장애 내성 MUST: 에러 시 GET→None(DB 폴백), SET→로그만. Redis 가 견적/배치를 막으면 안 된다.
|
||||||
"""
|
"""
|
||||||
import redis.asyncio as aioredis
|
import redis.asyncio as aioredis
|
||||||
|
|
||||||
from anchoring.config import RedisConfig
|
from anchoring.config import RedisConfig
|
||||||
from anchoring.constants import ANCHOR_RATE_MAX, ANCHOR_RATE_MIN, CACHE_TTL_SECONDS, REDIS_SOCKET_TIMEOUT
|
from anchoring.constants import ANCHORING_VALUE_MAX, ANCHORING_VALUE_MIN, CACHE_TTL_SECONDS, REDIS_SOCKET_TIMEOUT
|
||||||
from anchoring.log import LOG
|
from anchoring.log import LOG
|
||||||
|
|
||||||
_client: aioredis.Redis | None = None
|
_client: aioredis.Redis | None = None
|
||||||
@ -52,8 +52,8 @@ async def close_redis() -> None:
|
|||||||
_client = None
|
_client = None
|
||||||
|
|
||||||
|
|
||||||
def anchor_key(company_id, supplier_type: int, bracket_index: int) -> str:
|
def anchor_key(company_id, supplier_type: int, price_range_index: int) -> str:
|
||||||
return f"anchor:{company_id}:{supplier_type}:{bracket_index}"
|
return f"anchor:{company_id}:{supplier_type}:{price_range_index}"
|
||||||
|
|
||||||
|
|
||||||
async def ping() -> bool:
|
async def ping() -> bool:
|
||||||
@ -66,26 +66,26 @@ async def ping() -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def get_rate(company_id, supplier_type: int, bracket_index: int) -> int | None:
|
async def get_value(company_id, supplier_type: int, price_range_index: int) -> int | None:
|
||||||
"""캐시 조회. 미스·에러·클라이언트 미초기화 → None(호출측이 DB 폴백)."""
|
"""캐시 조회. 미스·에러·클라이언트 미초기화 → None(호출측이 DB 폴백)."""
|
||||||
if _client is None:
|
if _client is None:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
raw = await _client.get(anchor_key(company_id, supplier_type, bracket_index))
|
raw = await _client.get(anchor_key(company_id, supplier_type, price_range_index))
|
||||||
if raw is None:
|
if raw is None:
|
||||||
return None
|
return None
|
||||||
rate = int(raw)
|
value = int(raw)
|
||||||
# 방어: 캐시 오염(외부 SET 등)으로 정책 범위 밖 값이 오면 미스로 취급 → DB 폴백 + 재적재로 자가 교정
|
# 방어: 캐시 오염(외부 SET 등)으로 정책 범위 밖 값이 오면 미스로 취급 → DB 폴백 + 재적재로 자가 교정
|
||||||
if not (ANCHOR_RATE_MIN <= rate <= ANCHOR_RATE_MAX):
|
if not (ANCHORING_VALUE_MIN <= value <= ANCHORING_VALUE_MAX):
|
||||||
LOG.warning(f"[redis] 범위 밖 캐시 값 무시(오염 의심) key={anchor_key(company_id, supplier_type, bracket_index)} value={raw}")
|
LOG.warning(f"[redis] 범위 밖 캐시 값 무시(오염 의심) key={anchor_key(company_id, supplier_type, price_range_index)} value={raw}")
|
||||||
return None
|
return None
|
||||||
return rate
|
return value
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
_note_failure("get", anchor_key(company_id, supplier_type, bracket_index), ex)
|
_note_failure("get", anchor_key(company_id, supplier_type, price_range_index), ex)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def set_rate(company_id, supplier_type: int, bracket_index: int, rate: int, nx: bool = False) -> bool:
|
async def set_value(company_id, supplier_type: int, price_range_index: int, value: int, nx: bool = False) -> bool:
|
||||||
"""캐시 적재(best effort, TTL 7일). 실패해도 예외를 밖으로 던지지 않는다.
|
"""캐시 적재(best effort, TTL 7일). 실패해도 예외를 밖으로 던지지 않는다.
|
||||||
|
|
||||||
nx=True: 키가 없을 때만 적재 — 읽기 경로의 미스 백필용(배치가 방금 쓴 새 값을
|
nx=True: 키가 없을 때만 적재 — 읽기 경로의 미스 백필용(배치가 방금 쓴 새 값을
|
||||||
@ -94,8 +94,8 @@ async def set_rate(company_id, supplier_type: int, bracket_index: int, rate: int
|
|||||||
if _client is None:
|
if _client is None:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
await _client.set(anchor_key(company_id, supplier_type, bracket_index), str(rate), ex=CACHE_TTL_SECONDS, nx=nx)
|
await _client.set(anchor_key(company_id, supplier_type, price_range_index), str(value), ex=CACHE_TTL_SECONDS, nx=nx)
|
||||||
return True
|
return True
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
_note_failure("set", anchor_key(company_id, supplier_type, bracket_index), ex)
|
_note_failure("set", anchor_key(company_id, supplier_type, price_range_index), ex)
|
||||||
return False
|
return False
|
||||||
|
|||||||
@ -4,56 +4,58 @@
|
|||||||
"""
|
"""
|
||||||
from bisect import bisect_right
|
from bisect import bisect_right
|
||||||
|
|
||||||
from anchoring.base_table import get_base_rate_permille
|
from anchoring.base_table import get_base_anchoring_value
|
||||||
from anchoring.constants import (
|
from anchoring.constants import (
|
||||||
ANCHOR_RATE_MAX,
|
ANCHORING_VALUE_MAX,
|
||||||
ANCHOR_RATE_MIN,
|
ANCHORING_VALUE_MIN,
|
||||||
BRACKET_INDEX_MAX,
|
PRICE_RANGE_INDEX_MAX,
|
||||||
DELTA_PERMILLE,
|
ADJUSTMENT_STEP,
|
||||||
SAMPLE_THRESHOLD,
|
SAMPLE_THRESHOLD,
|
||||||
UPPER_BOUNDS,
|
UPPER_BOUNDS,
|
||||||
AnchoringSampleType,
|
AnchoringSampleType,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def calc_bracket_index(target_price: int) -> int:
|
def calc_price_range_index(target_price: int) -> int:
|
||||||
"""목표가 → 가격구간 인덱스(0-기반). §4.1 — 자릿수 계단식 사다리.
|
"""목표가 → 가격구간 인덱스(0-기반). §4.1 — 자릿수 계단식 사다리.
|
||||||
|
|
||||||
좌폐우개 [이전 ub, ub): 가격이 upper_bound 와 정확히 같으면 다음 칸.
|
좌폐우개 [이전 ub, ub): 가격이 upper_bound 와 정확히 같으면 다음 칸.
|
||||||
1억 이상은 마지막 인덱스로 클램프. 정적 테이블 idx = 반환값 + 1"""
|
1억 이상은 마지막 인덱스로 클램프. 정적 테이블 idx = 반환값 + 1"""
|
||||||
return min(bisect_right(UPPER_BOUNDS, target_price), BRACKET_INDEX_MAX)
|
return min(bisect_right(UPPER_BOUNDS, target_price), PRICE_RANGE_INDEX_MAX)
|
||||||
|
|
||||||
|
|
||||||
def calc_anchor_price(target_price: int, rate_permille: int) -> int:
|
def calc_anchoring_price(target_price: int, anchoring_value: int) -> int:
|
||||||
"""앵커링가 = 목표가 × (1 − A), 1원 단위 내림. §4.2 (정수 연산만)"""
|
"""앵커링가 = 목표가 × (1 − A), 1원 단위 내림. §4.2 (정수 연산만)"""
|
||||||
return target_price * (1000 - rate_permille) // 1000
|
return target_price * (1000 - anchoring_value) // 1000
|
||||||
|
|
||||||
|
|
||||||
def judge_sample_type(
|
def judge_sample_type(
|
||||||
is_done: bool, # sessions.status == DONE(3)
|
is_done: bool, # sessions.status == DONE(3)
|
||||||
bid_price: int | None, # 확정 투찰가(DONE 시)
|
bid_price: int | None, # 확정 투찰가(DONE 시)
|
||||||
last_offered_price: int | None, # 마지막 제시가 — NULL 이면 가격 흔적 없음
|
last_offer_price: int | None, # 마지막 제시가 — NULL 이면 가격 흔적 없음
|
||||||
anchor_price: int | None, # sessions.target_anchoring_price (박제 앵커)
|
anchoring_price: int | None, # sessions.anchoring_price (박제 앵커)
|
||||||
) -> int:
|
) -> int:
|
||||||
"""배치 시점 파생 판정("가격 흔적" 기준). §4.3 — 입력이 전부 종료 후 불변 컬럼이라 결정적.
|
"""배치 시점 파생 판정("가격 흔적" 기준). §4.3 — 입력이 전부 종료 후 불변 컬럼이라 결정적.
|
||||||
|
|
||||||
가격을 한 번이라도 써낸 협상만 표본: 앵커 이하 합의 = 성공,
|
가격을 한 번이라도 써낸 협상만 표본: 앵커 이하 합의 = 성공,
|
||||||
나머지(앵커 초과 합의·결렬·가격 쓰고 이탈) = 실패. 가격 흔적이 없으면 제외.
|
나머지(앵커 초과 합의·결렬·가격 쓰고 이탈) = 실패. 가격 흔적이 없으면 제외.
|
||||||
"""
|
"""
|
||||||
if anchor_price is None or last_offered_price is None:
|
if anchoring_price is None or last_offer_price is None:
|
||||||
return AnchoringSampleType.EXCLUDED.value
|
return AnchoringSampleType.EXCLUDED.value
|
||||||
if is_done and bid_price is not None and bid_price <= anchor_price:
|
if is_done and bid_price is not None and bid_price <= anchoring_price:
|
||||||
return AnchoringSampleType.BID_SUCCESS.value
|
return AnchoringSampleType.BID_SUCCESS.value
|
||||||
return AnchoringSampleType.BID_FAIL.value
|
return AnchoringSampleType.BID_FAIL.value
|
||||||
|
|
||||||
|
|
||||||
def evaluate_pending(
|
def evaluate_samples(
|
||||||
rate_before: int,
|
value_before: int,
|
||||||
sample_types: list[int], # 미처리 유효 표본 전량의 판정 코드
|
sample_types: list[int], # 미처리 유효 표본 전량의 판정 코드
|
||||||
supplier_type: int, # SMALLINT 코드 1/2/3
|
supplier_type: int, # SMALLINT 코드 1/2/3
|
||||||
) -> int | None:
|
) -> tuple[int, bool] | None:
|
||||||
"""누적 전량 평가. §4.4
|
"""누적 전량 평가. §4.4
|
||||||
반환: anchor_rate_after (평가 수행 시) / None (n < 10, 스킵·이월)
|
반환: (anchoring_value_after, clamped) (평가 수행 시) / None (n < 10, 스킵·이월)
|
||||||
|
clamped: 조정치가 [하한, 상한] 밖으로 나가 잘렸는지 — 경계값에서의 '유지'와 구분되는
|
||||||
|
실제 포화 신호(운영 지표용).
|
||||||
호출 측은 None 이 아니면 [조정 INSERT + 소비 마킹] 한 트랜잭션 + 캐시 SET 을 수행한다.
|
호출 측은 None 이 아니면 [조정 INSERT + 소비 마킹] 한 트랜잭션 + 캐시 SET 을 수행한다.
|
||||||
"""
|
"""
|
||||||
n = len(sample_types)
|
n = len(sample_types)
|
||||||
@ -61,21 +63,22 @@ def evaluate_pending(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
success = sum(1 for s in sample_types if s == AnchoringSampleType.BID_SUCCESS.value)
|
success = sum(1 for s in sample_types if s == AnchoringSampleType.BID_SUCCESS.value)
|
||||||
delta = DELTA_PERMILLE[supplier_type]
|
delta = ADJUSTMENT_STEP[supplier_type]
|
||||||
|
|
||||||
# r ≥ 0.60 ↔ success*10 ≥ n*6 (정수 비교로 부동소수점 회피)
|
# r ≥ 0.60 ↔ success*10 ≥ n*6 (정수 비교로 부동소수점 회피)
|
||||||
if success * 10 >= n * 6:
|
if success * 10 >= n * 6:
|
||||||
adjusted = rate_before + delta
|
adjusted = value_before + delta
|
||||||
elif success * 10 < n * 3: # r < 0.30
|
elif success * 10 < n * 3: # r < 0.30
|
||||||
adjusted = rate_before - delta
|
adjusted = value_before - delta
|
||||||
else: # 0.30 ≤ r < 0.60
|
else: # 0.30 ≤ r < 0.60
|
||||||
adjusted = rate_before
|
adjusted = value_before
|
||||||
|
|
||||||
return max(ANCHOR_RATE_MIN, min(ANCHOR_RATE_MAX, adjusted))
|
value_after = max(ANCHORING_VALUE_MIN, min(ANCHORING_VALUE_MAX, adjusted))
|
||||||
|
return value_after, value_after != adjusted
|
||||||
|
|
||||||
|
|
||||||
def get_current_rate(latest_adjusted_rate: int | None, bracket_index: int) -> int:
|
def get_current_value(latest_adjusted_value: int | None, price_range_index: int) -> int:
|
||||||
"""현재 앵커링 값. §4.5 — 조정 이력 없으면 정적 테이블 시작값."""
|
"""현재 앵커링 값. §4.5 — 조정 이력 없으면 정적 테이블 시작값."""
|
||||||
if latest_adjusted_rate is not None:
|
if latest_adjusted_value is not None:
|
||||||
return latest_adjusted_rate
|
return latest_adjusted_value
|
||||||
return get_base_rate_permille(bracket_index)
|
return get_base_anchoring_value(price_range_index)
|
||||||
|
|||||||
@ -15,7 +15,7 @@ from anchoring import db as adb
|
|||||||
from anchoring.config import load_config
|
from anchoring.config import load_config
|
||||||
|
|
||||||
CFG = load_config()
|
CFG = load_config()
|
||||||
_SCHEMA_SQL = Path(__file__).resolve().parents[1] / "schema.sql"
|
_SCHEMA_SQL = Path(__file__).resolve().parents[3] / "postgres-init" / "05-anchoring-schema.sql"
|
||||||
|
|
||||||
|
|
||||||
def _db_available() -> bool:
|
def _db_available() -> bool:
|
||||||
@ -40,7 +40,7 @@ requires_db = pytest.mark.skipif(not DB_OK, reason="로컬 Postgres(negosium_db)
|
|||||||
|
|
||||||
|
|
||||||
def _schema_statements() -> list[str]:
|
def _schema_statements() -> list[str]:
|
||||||
"""schema.sql 에서 psql 메타(\\connect)·주석을 제거하고 문장 단위로 분리."""
|
"""05-anchoring-schema.sql 에서 psql 메타(\\connect)·주석을 제거하고 문장 단위로 분리."""
|
||||||
lines = [
|
lines = [
|
||||||
line for line in _SCHEMA_SQL.read_text().splitlines()
|
line for line in _SCHEMA_SQL.read_text().splitlines()
|
||||||
if not line.startswith("\\") and not line.strip().startswith("--")
|
if not line.startswith("\\") and not line.strip().startswith("--")
|
||||||
@ -80,15 +80,15 @@ class Seeder:
|
|||||||
|
|
||||||
async def seed_session(
|
async def seed_session(
|
||||||
self, db, *,
|
self, db, *,
|
||||||
supplier_type=1, target_price=30_000, anchor_price=29_700, rate=10,
|
supplier_type=1, target_price=30_000, anchoring_price=29_700, anchoring_value=10,
|
||||||
status=3, bid_price=None, last_offered_price=..., qt_type=1,
|
status=3, bid_price=None, last_offer_price=..., qt_type=1,
|
||||||
):
|
):
|
||||||
"""종료 재협상 세션 1건 시드. 반환: session_id.
|
"""종료 재협상 세션 1건 시드. 반환: session_id.
|
||||||
|
|
||||||
last_offered_price 기본값은 bid_price(가격 흔적 = 투찰가). None 을 명시하면 가격 흔적 없는 세션.
|
last_offer_price 기본값은 bid_price(가격 흔적 = 투찰가). None 을 명시하면 가격 흔적 없는 세션.
|
||||||
"""
|
"""
|
||||||
if last_offered_price is ...:
|
if last_offer_price is ...:
|
||||||
last_offered_price = bid_price
|
last_offer_price = bid_price
|
||||||
await self._ensure_item(db)
|
await self._ensure_item(db)
|
||||||
qt_id = uuid.uuid4()
|
qt_id = uuid.uuid4()
|
||||||
self.quotation_ids.append(qt_id)
|
self.quotation_ids.append(qt_id)
|
||||||
@ -105,19 +105,19 @@ class Seeder:
|
|||||||
await db.execute(text(
|
await db.execute(text(
|
||||||
"INSERT INTO negotiation.sessions "
|
"INSERT INTO negotiation.sessions "
|
||||||
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, "
|
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, "
|
||||||
" target_price, target_anchoring_price, anchor_rate_permille, last_offered_price, "
|
" target_price, anchoring_price, anchoring_value, last_offer_price, "
|
||||||
" status, bid_price, end_time) "
|
" status, bid_price, end_time) "
|
||||||
"VALUES (:sid, :qid, :iid, :supid, 'AT-N', 1, :qtype, :tp, :ap, :rate, :lop, :status, :bid, now())"
|
"VALUES (:sid, :qid, :iid, :supid, 'AT-N', 1, :qtype, :tp, :ap, :value, :lop, :status, :bid, now())"
|
||||||
), {
|
), {
|
||||||
"sid": session_id, "qid": qt_id, "iid": self.item_id, "supid": uuid.uuid4(),
|
"sid": session_id, "qid": qt_id, "iid": self.item_id, "supid": uuid.uuid4(),
|
||||||
"qtype": qt_type, "tp": target_price, "ap": anchor_price, "rate": rate,
|
"qtype": qt_type, "tp": target_price, "ap": anchoring_price, "value": anchoring_value,
|
||||||
"lop": last_offered_price, "status": status, "bid": bid_price,
|
"lop": last_offer_price, "status": status, "bid": bid_price,
|
||||||
})
|
})
|
||||||
return session_id
|
return session_id
|
||||||
|
|
||||||
async def cleanup(self, db):
|
async def cleanup(self, db):
|
||||||
await db.execute(text(
|
await db.execute(text(
|
||||||
"DELETE FROM anchoring.rate_adjustments WHERE company_id = :cid"
|
"DELETE FROM anchoring.adjustments WHERE company_id = :cid"
|
||||||
), {"cid": self.company_id})
|
), {"cid": self.company_id})
|
||||||
if self.quotation_ids:
|
if self.quotation_ids:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
|
|||||||
@ -9,8 +9,8 @@ from sqlalchemy import select, text
|
|||||||
|
|
||||||
from anchoring import db as adb
|
from anchoring import db as adb
|
||||||
from anchoring.batch import MarkingConflictError, _evaluate_cell, run_evaluation_batch
|
from anchoring.batch import MarkingConflictError, _evaluate_cell, run_evaluation_batch
|
||||||
from anchoring.models import RateAdjustment, Session
|
from anchoring.models import Adjustment, Session
|
||||||
from anchoring.reader import get_anchor_rate
|
from anchoring.reader import get_current_anchoring_value
|
||||||
from conftest import requires_db
|
from conftest import requires_db
|
||||||
|
|
||||||
pytestmark = requires_db
|
pytestmark = requires_db
|
||||||
@ -20,7 +20,7 @@ async def _run_batch(*seeders):
|
|||||||
"""테스트 전용: 시드한 회사로 스코프 — 공유 dev DB 의 실데이터를 소비하지 않는다."""
|
"""테스트 전용: 시드한 회사로 스코프 — 공유 dev DB 의 실데이터를 소비하지 않는다."""
|
||||||
return await run_evaluation_batch(force=True, company_ids=[s.company_id for s in seeders])
|
return await run_evaluation_batch(force=True, company_ids=[s.company_id for s in seeders])
|
||||||
|
|
||||||
# 시드 기본값: target 30,000 / rate 10‰ / anchor 29,700 → bracket 12 ("3만 원대" 칸)
|
# 시드 기본값: target 30,000 / anchoring_value 10‰ / anchoring_price 29,700 → price_range 12 ("3만 원대" 칸)
|
||||||
BRACKET = 12
|
BRACKET = 12
|
||||||
SUCCESS_BID = 29_000 # ≤ anchor → BID_SUCCESS
|
SUCCESS_BID = 29_000 # ≤ anchor → BID_SUCCESS
|
||||||
FAIL_BID = 29_999 # > anchor → BID_FAIL
|
FAIL_BID = 29_999 # > anchor → BID_FAIL
|
||||||
@ -28,15 +28,15 @@ FAIL_BID = 29_999 # > anchor → BID_FAIL
|
|||||||
|
|
||||||
async def _adjustments(db, seeder):
|
async def _adjustments(db, seeder):
|
||||||
stmt = (
|
stmt = (
|
||||||
select(RateAdjustment)
|
select(Adjustment)
|
||||||
.where(RateAdjustment.company_id == seeder.company_id)
|
.where(Adjustment.company_id == seeder.company_id)
|
||||||
.order_by(RateAdjustment.id)
|
.order_by(Adjustment.adjustment_id)
|
||||||
)
|
)
|
||||||
return (await db.execute(stmt)).scalars().all()
|
return (await db.execute(stmt)).scalars().all()
|
||||||
|
|
||||||
|
|
||||||
async def _marks(db, session_ids):
|
async def _marks(db, session_ids):
|
||||||
stmt = select(Session.session_id, Session.anchoring_adjustment_id).where(Session.session_id.in_(session_ids))
|
stmt = select(Session.session_id, Session.used_by_adjustment_id).where(Session.session_id.in_(session_ids))
|
||||||
return dict((await db.execute(stmt)).all())
|
return dict((await db.execute(stmt)).all())
|
||||||
|
|
||||||
|
|
||||||
@ -54,9 +54,9 @@ async def test_full_cycle_and_idempotency(seeder, caplog):
|
|||||||
async with adb.session_scope() as db:
|
async with adb.session_scope() as db:
|
||||||
ids = await _seed_mixed(db, seeder, success=8, fail=2) # DONE 인데 앵커 초과(와일드카드 상단 등)
|
ids = await _seed_mixed(db, seeder, success=8, fail=2) # DONE 인데 앵커 초과(와일드카드 상단 등)
|
||||||
for _ in range(2): # 가격 쓰고 결렬(REJECTED) = 실패
|
for _ in range(2): # 가격 쓰고 결렬(REJECTED) = 실패
|
||||||
ids.append(await seeder.seed_session(db, status=5, bid_price=None, last_offered_price=FAIL_BID))
|
ids.append(await seeder.seed_session(db, status=5, bid_price=None, last_offer_price=FAIL_BID))
|
||||||
# 가격 쓰고 이탈 → 견적 마감 시 일괄 NOT_PARTICIPATED = 실패 (중간 이탈 시나리오)
|
# 가격 쓰고 이탈 → 견적 마감 시 일괄 NOT_PARTICIPATED = 실패 (중간 이탈 시나리오)
|
||||||
ids.append(await seeder.seed_session(db, status=4, bid_price=None, last_offered_price=FAIL_BID))
|
ids.append(await seeder.seed_session(db, status=4, bid_price=None, last_offer_price=FAIL_BID))
|
||||||
# 합계 13건, 성공 8 → r≈0.615 → +20
|
# 합계 13건, 성공 8 → r≈0.615 → +20
|
||||||
|
|
||||||
with caplog.at_level(logging.INFO, logger="anchoring"):
|
with caplog.at_level(logging.INFO, logger="anchoring"):
|
||||||
@ -72,26 +72,26 @@ async def test_full_cycle_and_idempotency(seeder, caplog):
|
|||||||
adjustments = await _adjustments(db, seeder)
|
adjustments = await _adjustments(db, seeder)
|
||||||
assert len(adjustments) == 1
|
assert len(adjustments) == 1
|
||||||
adj = adjustments[0]
|
adj = adjustments[0]
|
||||||
assert (adj.nego_count, adj.success_count) == (13, 8)
|
assert (adj.sample_count, adj.success_count) == (13, 8)
|
||||||
assert (adj.anchor_rate_before, adj.anchor_rate_after) == (10, 30)
|
assert (adj.anchoring_value_before, adj.anchoring_value_after) == (10, 30)
|
||||||
assert sorted(adj.consumed_session_ids) == sorted(str(i) for i in ids)
|
assert sorted(adj.used_session_ids) == sorted(str(i) for i in ids)
|
||||||
marks = await _marks(db, ids)
|
marks = await _marks(db, ids)
|
||||||
assert all(v == adj.id for v in marks.values()) # 13건 모두 소비 마킹
|
assert all(v == adj.adjustment_id for v in marks.values()) # 13건 모두 소비 마킹
|
||||||
|
|
||||||
# 재실행 — 마킹 멱등: 우리 칸 조정은 그대로 1건
|
# 재실행 — 마킹 멱등: 우리 칸 조정은 그대로 1건
|
||||||
await _run_batch(seeder)
|
await _run_batch(seeder)
|
||||||
async with adb.session_scope() as db:
|
async with adb.session_scope() as db:
|
||||||
assert len(await _adjustments(db, seeder)) == 1
|
assert len(await _adjustments(db, seeder)) == 1
|
||||||
|
|
||||||
# 조회용 뷰 — rate_history(이전→새 값 리스트업) / current_rates(칸별 현재값)
|
# 조회용 뷰 — value_history(이전→새 값 리스트업) / current_values(칸별 현재값)
|
||||||
hist = (await db.execute(text(
|
hist = (await db.execute(text(
|
||||||
"SELECT anchor_rate_before, anchor_rate_after, delta_permille, success_rate "
|
"SELECT anchoring_value_before, anchoring_value_after, value_change, success_rate "
|
||||||
"FROM anchoring.rate_history WHERE company_id = :c"), {"c": seeder.company_id})).one()
|
"FROM anchoring.value_history WHERE company_id = :c"), {"c": seeder.company_id})).one()
|
||||||
assert (hist.anchor_rate_before, hist.anchor_rate_after, hist.delta_permille) == (10, 30, 20)
|
assert (hist.anchoring_value_before, hist.anchoring_value_after, hist.value_change) == (10, 30, 20)
|
||||||
assert float(hist.success_rate) == 0.615
|
assert float(hist.success_rate) == 0.615
|
||||||
cur = (await db.execute(text(
|
cur = (await db.execute(text(
|
||||||
"SELECT anchor_rate_permille FROM anchoring.current_rates "
|
"SELECT anchoring_value FROM anchoring.current_values "
|
||||||
"WHERE company_id = :c AND supplier_type = 1 AND price_bracket_index = :b"),
|
"WHERE company_id = :c AND supplier_type = 1 AND price_range_index = :b"),
|
||||||
{"c": seeder.company_id, "b": BRACKET})).scalar_one()
|
{"c": seeder.company_id, "b": BRACKET})).scalar_one()
|
||||||
assert cur == 30
|
assert cur == 30
|
||||||
|
|
||||||
@ -113,10 +113,10 @@ async def test_carryover(seeder):
|
|||||||
async with adb.session_scope() as db:
|
async with adb.session_scope() as db:
|
||||||
adjustments = await _adjustments(db, seeder)
|
adjustments = await _adjustments(db, seeder)
|
||||||
assert len(adjustments) == 1
|
assert len(adjustments) == 1
|
||||||
assert adjustments[0].nego_count == 13 # 4주치 전량 1회 평가
|
assert adjustments[0].sample_count == 13 # 4주치 전량 1회 평가
|
||||||
assert adjustments[0].anchor_rate_after == 30
|
assert adjustments[0].anchoring_value_after == 30
|
||||||
marks = await _marks(db, first + second)
|
marks = await _marks(db, first + second)
|
||||||
assert all(v == adjustments[0].id for v in marks.values())
|
assert all(v == adjustments[0].adjustment_id for v in marks.values())
|
||||||
|
|
||||||
|
|
||||||
# ── §11.5: 회사 격리 + 현재값 조회(무Redis DB 폴백) + δ 유형 차원 ──
|
# ── §11.5: 회사 격리 + 현재값 조회(무Redis DB 폴백) + δ 유형 차원 ──
|
||||||
@ -130,19 +130,19 @@ async def test_company_isolation_and_reader(seeder):
|
|||||||
other_company = uuid.uuid4()
|
other_company = uuid.uuid4()
|
||||||
async with adb.session_scope() as db:
|
async with adb.session_scope() as db:
|
||||||
adjustments = await _adjustments(db, seeder)
|
adjustments = await _adjustments(db, seeder)
|
||||||
by_type = {a.supplier_type: a.anchor_rate_after for a in adjustments}
|
by_type = {a.supplier_type: a.anchoring_value_after for a in adjustments}
|
||||||
assert by_type == {1: 30, 2: 20}
|
assert by_type == {1: 30, 2: 20}
|
||||||
# 조정된 칸은 새 rate, 타사 같은 (유형,구간) 칸은 정적 테이블 시작값
|
# 조정된 칸은 새 anchoring_value, 타사 같은 (유형,구간) 칸은 정적 테이블 시작값
|
||||||
assert await get_anchor_rate(db, seeder.company_id, 1, BRACKET) == 30
|
assert await get_current_anchoring_value(db, seeder.company_id, 1, BRACKET) == 30
|
||||||
assert await get_anchor_rate(db, other_company, 1, BRACKET) == 10
|
assert await get_current_anchoring_value(db, other_company, 1, BRACKET) == 10
|
||||||
|
|
||||||
|
|
||||||
# ── §11.5: EXCLUDED 마킹 0 + 유효 n<10 이월 + supplier_type 미지정 ──
|
# ── §11.5: EXCLUDED 마킹 0 + 유효 n<10 이월 + supplier_type 미지정 ──
|
||||||
async def test_excluded_and_unsampleable(seeder):
|
async def test_excluded_and_unsampleable(seeder):
|
||||||
async with adb.session_scope() as db:
|
async with adb.session_scope() as db:
|
||||||
# 가격 흔적 없는 종료(무가격 결렬·미참여) → EXCLUDED
|
# 가격 흔적 없는 종료(무가격 결렬·미참여) → EXCLUDED
|
||||||
excluded = [await seeder.seed_session(db, status=5, last_offered_price=None) for _ in range(8)]
|
excluded = [await seeder.seed_session(db, status=5, last_offer_price=None) for _ in range(8)]
|
||||||
excluded += [await seeder.seed_session(db, status=4, last_offered_price=None) for _ in range(7)]
|
excluded += [await seeder.seed_session(db, status=4, last_offer_price=None) for _ in range(7)]
|
||||||
valid = await _seed_mixed(db, seeder, success=5, fail=0) # 유효 5 < 10
|
valid = await _seed_mixed(db, seeder, success=5, fail=0) # 유효 5 < 10
|
||||||
untyped = [await seeder.seed_session(db, supplier_type=0, bid_price=SUCCESS_BID)] # 칸 구성 불가
|
untyped = [await seeder.seed_session(db, supplier_type=0, bid_price=SUCCESS_BID)] # 칸 구성 불가
|
||||||
untyped.append(await seeder.seed_session(db, supplier_type=None, bid_price=SUCCESS_BID)) # NULL 도 동일(§13-6)
|
untyped.append(await seeder.seed_session(db, supplier_type=None, bid_price=SUCCESS_BID)) # NULL 도 동일(§13-6)
|
||||||
@ -163,7 +163,7 @@ async def test_marking_conflict_rolls_back(seeder):
|
|||||||
ids = await _seed_mixed(db, seeder, success=10, fail=0)
|
ids = await _seed_mixed(db, seeder, success=10, fail=0)
|
||||||
# 경합 시뮬레이션: 1건을 다른 실행이 먼저 소비한 상태로 만든다
|
# 경합 시뮬레이션: 1건을 다른 실행이 먼저 소비한 상태로 만든다
|
||||||
await db.execute(text(
|
await db.execute(text(
|
||||||
"UPDATE negotiation.sessions SET anchoring_adjustment_id = 999999 WHERE session_id = :sid"
|
"UPDATE negotiation.sessions SET used_by_adjustment_id = 999999 WHERE session_id = :sid"
|
||||||
), {"sid": ids[0]})
|
), {"sid": ids[0]})
|
||||||
|
|
||||||
samples = [(sid, 1) for sid in ids] # 10건 전부 BID_SUCCESS 로 평가 시도
|
samples = [(sid, 1) for sid in ids] # 10건 전부 BID_SUCCESS 로 평가 시도
|
||||||
@ -184,7 +184,7 @@ async def test_marking_conflict_rolls_back(seeder):
|
|||||||
async def test_priced_rate_zero_warns(seeder, caplog):
|
async def test_priced_rate_zero_warns(seeder, caplog):
|
||||||
async with adb.session_scope() as db:
|
async with adb.session_scope() as db:
|
||||||
for _ in range(3): # 전부 가격 흔적 없는 종료 → priced_rate 0
|
for _ in range(3): # 전부 가격 흔적 없는 종료 → priced_rate 0
|
||||||
await seeder.seed_session(db, status=5, last_offered_price=None)
|
await seeder.seed_session(db, status=5, last_offer_price=None)
|
||||||
|
|
||||||
with caplog.at_level(logging.WARNING, logger="anchoring"):
|
with caplog.at_level(logging.WARNING, logger="anchoring"):
|
||||||
await run_evaluation_batch(force=True, company_ids=[seeder.company_id])
|
await run_evaluation_batch(force=True, company_ids=[seeder.company_id])
|
||||||
@ -195,7 +195,7 @@ async def test_priced_rate_zero_warns(seeder, caplog):
|
|||||||
async def test_dry_run_changes_nothing(seeder, caplog):
|
async def test_dry_run_changes_nothing(seeder, caplog):
|
||||||
async with adb.session_scope() as db:
|
async with adb.session_scope() as db:
|
||||||
ids = await _seed_mixed(db, seeder, success=10, fail=0)
|
ids = await _seed_mixed(db, seeder, success=10, fail=0)
|
||||||
excluded = [await seeder.seed_session(db, status=5, last_offered_price=None)]
|
excluded = [await seeder.seed_session(db, status=5, last_offer_price=None)]
|
||||||
|
|
||||||
with caplog.at_level(logging.INFO, logger="anchoring"):
|
with caplog.at_level(logging.INFO, logger="anchoring"):
|
||||||
result = await run_evaluation_batch(force=True, company_ids=[seeder.company_id], dry_run=True)
|
result = await run_evaluation_batch(force=True, company_ids=[seeder.company_id], dry_run=True)
|
||||||
@ -216,8 +216,8 @@ async def test_dry_run_changes_nothing(seeder, caplog):
|
|||||||
# ── 박제 정합 감시: 정수식과 박제 anchor 불일치 → WARN ────
|
# ── 박제 정합 감시: 정수식과 박제 anchor 불일치 → WARN ────
|
||||||
async def test_snapshot_mismatch_warns(seeder, caplog):
|
async def test_snapshot_mismatch_warns(seeder, caplog):
|
||||||
async with adb.session_scope() as db:
|
async with adb.session_scope() as db:
|
||||||
# rate 10‰ 기준 정수식 anchor 는 29,700 — 29,000 으로 박제된 세션은 이식 오류 신호
|
# anchoring_value 10‰ 기준 정수식 앵커가는 29,700 — 29,000 으로 박제된 세션은 이식 오류 신호
|
||||||
await seeder.seed_session(db, rate=10, anchor_price=29_000, bid_price=28_000)
|
await seeder.seed_session(db, anchoring_value=10, anchoring_price=29_000, bid_price=28_000)
|
||||||
|
|
||||||
with caplog.at_level(logging.WARNING, logger="anchoring"):
|
with caplog.at_level(logging.WARNING, logger="anchoring"):
|
||||||
await run_evaluation_batch(force=True, company_ids=[seeder.company_id], dry_run=True)
|
await run_evaluation_batch(force=True, company_ids=[seeder.company_id], dry_run=True)
|
||||||
|
|||||||
@ -6,14 +6,14 @@ from datetime import datetime
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from anchoring.base_table import BaseTableError, _validate, get_base_rate_permille, load_base_table
|
from anchoring.base_table import BaseTableError, _validate, get_base_anchoring_value, load_base_table
|
||||||
from anchoring.constants import BRACKET_COUNT, UPPER_BOUNDS, AnchoringSampleType
|
from anchoring.constants import PRICE_RANGE_COUNT, UPPER_BOUNDS, AnchoringSampleType
|
||||||
from anchoring.batch import is_evaluation_week
|
from anchoring.batch import is_evaluation_week
|
||||||
from anchoring.service import (
|
from anchoring.service import (
|
||||||
calc_anchor_price,
|
calc_anchoring_price,
|
||||||
calc_bracket_index,
|
calc_price_range_index,
|
||||||
evaluate_pending,
|
evaluate_samples,
|
||||||
get_current_rate,
|
get_current_value,
|
||||||
judge_sample_type,
|
judge_sample_type,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -23,32 +23,32 @@ E = AnchoringSampleType.EXCLUDED.value
|
|||||||
|
|
||||||
|
|
||||||
# ── §11.1 앵커링가 계산 (내림 검증) ──────────────────────
|
# ── §11.1 앵커링가 계산 (내림 검증) ──────────────────────
|
||||||
def test_calc_anchor_price_floor():
|
def test_calc_anchoring_price_floor():
|
||||||
assert calc_anchor_price(30_000, 200) == 24_000
|
assert calc_anchoring_price(30_000, 200) == 24_000
|
||||||
assert calc_anchor_price(26_706, 10) == 26_438 # 26,438.94 → 내림
|
assert calc_anchoring_price(26_706, 10) == 26_438 # 26,438.94 → 내림
|
||||||
assert calc_anchor_price(29_999, 15) == 29_549 # 29,549.015 → 내림
|
assert calc_anchoring_price(29_999, 15) == 29_549 # 29,549.015 → 내림
|
||||||
assert calc_anchor_price(0, 10) == 0
|
assert calc_anchoring_price(0, 10) == 0
|
||||||
|
|
||||||
|
|
||||||
# ── §11.2 구간 인덱스 (자릿수 계단식 사다리 · 상한 클램프) ──
|
# ── §11.2 구간 인덱스 (자릿수 계단식 사다리 · 상한 클램프) ──
|
||||||
def test_bracket_index():
|
def test_price_range_index():
|
||||||
assert calc_bracket_index(0) == 0 # [0, 1,000) 통일 칸
|
assert calc_price_range_index(0) == 0 # [0, 1,000) 통일 칸
|
||||||
assert calc_bracket_index(999) == 0
|
assert calc_price_range_index(999) == 0
|
||||||
assert calc_bracket_index(1_000) == 1 # 경계는 상위 구간
|
assert calc_price_range_index(1_000) == 1 # 경계는 상위 구간
|
||||||
assert calc_bracket_index(1_999) == 1 # 1천 원대
|
assert calc_price_range_index(1_999) == 1 # 1천 원대
|
||||||
assert calc_bracket_index(9_999) == 9 # 9천 원대
|
assert calc_price_range_index(9_999) == 9 # 9천 원대
|
||||||
assert calc_bracket_index(10_000) == 10 # 1만 원대 진입
|
assert calc_price_range_index(10_000) == 10 # 1만 원대 진입
|
||||||
assert calc_bracket_index(30_000) == 12 # 3만 원대
|
assert calc_price_range_index(30_000) == 12 # 3만 원대
|
||||||
assert calc_bracket_index(99_999) == 18 # 9만 원대
|
assert calc_price_range_index(99_999) == 18 # 9만 원대
|
||||||
assert calc_bracket_index(150_000) == 19 # 10만 원대
|
assert calc_price_range_index(150_000) == 19 # 10만 원대
|
||||||
assert calc_bracket_index(99_999_999) == 45 # 마지막 구간(9천만 원대) 진입
|
assert calc_price_range_index(99_999_999) == 45 # 마지막 구간(9천만 원대) 진입
|
||||||
assert calc_bracket_index(100_000_000) == 45 # 정확히 1억 → 마지막 칸
|
assert calc_price_range_index(100_000_000) == 45 # 정확히 1억 → 마지막 칸
|
||||||
assert calc_bracket_index(150_000_000) == 45 # 1억 초과 → 마지막 인덱스 클램프
|
assert calc_price_range_index(150_000_000) == 45 # 1억 초과 → 마지막 인덱스 클램프
|
||||||
|
|
||||||
|
|
||||||
def test_ladder_shape():
|
def test_ladder_shape():
|
||||||
"""사다리 자체 검증: 1 + 자릿수(5)×9 = 46칸, 단조 증가, 마지막 1억."""
|
"""사다리 자체 검증: 1 + 자릿수(5)×9 = 46칸, 단조 증가, 마지막 1억."""
|
||||||
assert BRACKET_COUNT == 46
|
assert PRICE_RANGE_COUNT == 46
|
||||||
assert UPPER_BOUNDS[0] == 1_000 and UPPER_BOUNDS[-1] == 100_000_000
|
assert UPPER_BOUNDS[0] == 1_000 and UPPER_BOUNDS[-1] == 100_000_000
|
||||||
assert list(UPPER_BOUNDS) == sorted(set(UPPER_BOUNDS))
|
assert list(UPPER_BOUNDS) == sorted(set(UPPER_BOUNDS))
|
||||||
assert UPPER_BOUNDS[9] == 10_000 and UPPER_BOUNDS[18] == 100_000 # 자릿수 경계
|
assert UPPER_BOUNDS[9] == 10_000 and UPPER_BOUNDS[18] == 100_000 # 자릿수 경계
|
||||||
@ -57,8 +57,8 @@ def test_ladder_shape():
|
|||||||
# ── §2 정적 테이블 로드·검증 ─────────────────────────────
|
# ── §2 정적 테이블 로드·검증 ─────────────────────────────
|
||||||
def test_base_table_load_and_values():
|
def test_base_table_load_and_values():
|
||||||
load_base_table()
|
load_base_table()
|
||||||
assert get_base_rate_permille(0) == 10
|
assert get_base_anchoring_value(0) == 10
|
||||||
assert get_base_rate_permille(45) == 10
|
assert get_base_anchoring_value(45) == 10
|
||||||
|
|
||||||
|
|
||||||
def _rows():
|
def _rows():
|
||||||
@ -69,8 +69,8 @@ def _rows():
|
|||||||
|
|
||||||
|
|
||||||
def test_base_table_validate_ok():
|
def test_base_table_validate_ok():
|
||||||
rates = _validate(_rows())
|
values = _validate(_rows())
|
||||||
assert len(rates) == BRACKET_COUNT and set(rates) == {10}
|
assert len(values) == PRICE_RANGE_COUNT and set(values) == {10}
|
||||||
|
|
||||||
|
|
||||||
def test_base_table_validate_rejects_bad():
|
def test_base_table_validate_rejects_bad():
|
||||||
@ -95,32 +95,35 @@ def _pending(success: int, fail: int) -> list[int]:
|
|||||||
return [S] * success + [F] * fail
|
return [S] * success + [F] * fail
|
||||||
|
|
||||||
|
|
||||||
def test_evaluate_pending_distribution():
|
def test_evaluate_samples_distribution():
|
||||||
assert evaluate_pending(10, _pending(8, 5), 1) == 30 # 13건 r≈0.615 → +20
|
assert evaluate_samples(10, _pending(8, 5), 1) == (30, False) # 13건 r≈0.615 → +20
|
||||||
assert evaluate_pending(10, _pending(7, 6), 1) == 10 # r≈0.538 → 유지
|
assert evaluate_samples(10, _pending(7, 6), 1) == (10, False) # r≈0.538 → 유지
|
||||||
assert evaluate_pending(10, _pending(3, 10), 1) == 10 # r≈0.231 → −20, 하한 clamp
|
assert evaluate_samples(10, _pending(3, 10), 1) == (10, True) # r≈0.231 → −20, 하한 clamp
|
||||||
assert evaluate_pending(10, _pending(6, 4), 1) == 30 # r=0.60 정확히 → 경계 포함 +20
|
assert evaluate_samples(10, _pending(6, 4), 1) == (30, False) # r=0.60 정확히 → 경계 포함 +20
|
||||||
assert evaluate_pending(10, _pending(3, 7), 1) == 10 # r=0.30 정확히 → 유지
|
assert evaluate_samples(10, _pending(3, 7), 1) == (10, False) # r=0.30 정확히 → 유지
|
||||||
assert evaluate_pending(10, _pending(9, 0), 1) is None # n=9 → 평가 안 함(이월)
|
assert evaluate_samples(10, _pending(9, 0), 1) is None # n=9 → 평가 안 함(이월)
|
||||||
|
|
||||||
|
|
||||||
def test_evaluate_pending_delta_swap_guard():
|
def test_evaluate_samples_delta_swap_guard():
|
||||||
"""δ 스왑 가드 (MUST): 코드 2=제조=±10, 3=총판=±15."""
|
"""δ 스왑 가드 (MUST): 코드 2=제조=±10, 3=총판=±15."""
|
||||||
all_success = [S] * 10
|
all_success = [S] * 10
|
||||||
assert evaluate_pending(10, all_success, 2) == 20 # 제조 +10
|
assert evaluate_samples(10, all_success, 2) == (20, False) # 제조 +10
|
||||||
assert evaluate_pending(10, all_success, 3) == 25 # 총판 +15
|
assert evaluate_samples(10, all_success, 3) == (25, False) # 총판 +15
|
||||||
all_fail = [F] * 10
|
all_fail = [F] * 10
|
||||||
assert evaluate_pending(100, all_fail, 2) == 90 # 제조 −10
|
assert evaluate_samples(100, all_fail, 2) == (90, False) # 제조 −10
|
||||||
assert evaluate_pending(100, all_fail, 3) == 85 # 총판 −15
|
assert evaluate_samples(100, all_fail, 3) == (85, False) # 총판 −15
|
||||||
|
|
||||||
|
|
||||||
def test_evaluate_pending_clamp_upper():
|
def test_evaluate_samples_clamp_upper():
|
||||||
assert evaluate_pending(200, _pending(9, 1), 1) == 200 # 상한 clamp — 조정 레코드는 호출측이 INSERT
|
# 상한 clamp — 조정 레코드는 호출측이 INSERT
|
||||||
|
assert evaluate_samples(200, _pending(9, 1), 1) == (200, True)
|
||||||
|
# 경계값에서의 '유지'(hold 밴드)는 clamp 가 아니다 — clamped 지표 오염 방지
|
||||||
|
assert evaluate_samples(200, _pending(5, 5), 1) == (200, False)
|
||||||
|
|
||||||
|
|
||||||
# ── §11.4 파생 판정 ("가격 흔적" 기준) ────────────────────
|
# ── §11.4 파생 판정 ("가격 흔적" 기준) ────────────────────
|
||||||
def test_judge_sample_type():
|
def test_judge_sample_type():
|
||||||
# judge_sample_type(is_done, bid_price, last_offered_price, anchor_price)
|
# judge_sample_type(is_done, bid_price, last_offer_price, anchoring_price)
|
||||||
assert judge_sample_type(True, 24_000, 24_000, 24_000) == S # 같아도 성공
|
assert judge_sample_type(True, 24_000, 24_000, 24_000) == S # 같아도 성공
|
||||||
assert judge_sample_type(True, 24_001, 24_001, 24_000) == F # 앵커 초과 합의(와일드카드 등)
|
assert judge_sample_type(True, 24_001, 24_001, 24_000) == F # 앵커 초과 합의(와일드카드 등)
|
||||||
assert judge_sample_type(False, None, 25_000, 24_000) == F # 가격 쓰고 결렬(REJECTED)
|
assert judge_sample_type(False, None, 25_000, 24_000) == F # 가격 쓰고 결렬(REJECTED)
|
||||||
@ -130,9 +133,9 @@ def test_judge_sample_type():
|
|||||||
|
|
||||||
|
|
||||||
# ── §4.5 현재 값 조회 ────────────────────────────────────
|
# ── §4.5 현재 값 조회 ────────────────────────────────────
|
||||||
def test_get_current_rate():
|
def test_get_current_value():
|
||||||
assert get_current_rate(70, 0) == 70
|
assert get_current_value(70, 0) == 70
|
||||||
assert get_current_rate(None, 0) == 10 # 이력 없으면 정적 테이블 시작값
|
assert get_current_value(None, 0) == 10 # 이력 없으면 정적 테이블 시작값
|
||||||
|
|
||||||
|
|
||||||
# ── §8 격주 게이트 (ISO 주차 짝수 토요일만 평가) ──────────
|
# ── §8 격주 게이트 (ISO 주차 짝수 토요일만 평가) ──────────
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user