feat(negodata): 앵커링 v1.2 적용 — 칸 rate 조회·정수 박제·재생성 앵커 상속 폐지

- common/anchoring 이식 패키지 신설: 원본(schedules/anchoring)에서 읽기 경로 발췌
  (constants·base_table·service) + reader 이식판(Redis 미사용 — current_rates 뷰
  단일 쿼리, 실패·무이력 시 정적 테이블 폴백으로 견적 생성 무중단)
- _build_quotation: 목표가 산정과 앵커 산출 분리 — 칸(items.company_id ×
  quotations.supplier_type × 가격구간) rate 로 tp*(1000-rate)//1000 정수 박제,
  anchor_rate_permille 동시 기록. quotation_settings.anchoring_value 계산 사용 중단
- 재생성(regenerate_next_round): target_price 만 상속, 앵커는 생성 시점 rate 재계산
  (인수인계 규칙 1 — 상속 폐지)
- sessions 모델 anchor_rate_permille 매핑, crud get_item_companies 신설
- 테스트 6종 신설(스키마 부재 폴백·칸별 조정 반영·유형 미지정·재생성 재계산·
  bracket 경계 골든 벡터) — 전체 스위트 50 통과
- negodata 담당자 승인 하 직접 적용. 6개월 압축 시뮬레이션(격주 배치 13회,
  세션 522건)으로 박제→소비→재박제 루프·클램프·이월·이중소비 방지 검증 완료

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
민헌 2026-07-04 16:37:27 +09:00
parent c98e5fa7bb
commit b362edf0d7
10 changed files with 529 additions and 28 deletions

View File

@ -0,0 +1,22 @@
"""앵커링 v1.2 읽기 경로 이식 패키지.
원본: `schedules/anchoring/src/anchoring` (자립 모듈). 견적/세션 생성 시
칸(회사 × 협력사유형 × 가격구간) rate 를 조회해 앵커링가를 정수 연산으로 박제하는 데
필요한 부분만 이식했다 — 명세: `schedules/anchoring/docs/인수인계.md` §1.
값 조정(격주 배치)·표본 판정은 이식 대상이 아니다(schedules/anchoring 서비스 담당).
상수·계산식을 고칠 일이 생기면 원본 모듈과 반드시 함께 고친다(단독 수정 금지).
"""
from common.anchoring.base_table import get_base_rate_permille, load_base_table
from common.anchoring.constants import SAMPLEABLE_SUPPLIER_TYPES
from common.anchoring.reader import fetch_current_rates
from common.anchoring.service import calc_anchor_price, calc_bracket_index
__all__ = [
"SAMPLEABLE_SUPPLIER_TYPES",
"calc_anchor_price",
"calc_bracket_index",
"fetch_current_rates",
"get_base_rate_permille",
"load_base_table",
]

View File

@ -0,0 +1,59 @@
"""정적 기본 테이블 — 칸 시작값의 유일한 소스. 원본: schedules/anchoring/src/anchoring/base_table.py
resources/anchoring_base.json(46행 사다리, 불변)을 최초 사용 시 메모리에 로드한다.
DB 에 저장하지 않으며 런타임에 절대 수정하지 않는다. 검증 실패 시 예외(견적 생성 차단이 아니라
잘못된 리소스 배포를 조기에 드러내기 위함 — 파일은 코드와 함께 배포되므로 정상 배포에선 실패하지 않는다).
"""
import json
from pathlib import Path
from common.anchoring.constants import BRACKET_COUNT, UPPER_BOUNDS
_RESOURCE = Path(__file__).parent / "resources" / "anchoring_base.json"
_rates: list[int] | None = None # bracket_index → 시작값(‰)
class BaseTableError(RuntimeError):
"""정적 테이블 로드/검증 실패."""
def _validate(rows: list) -> list[int]:
"""행 검증 후 천분율 정수 리스트로 변환. 실패 시 BaseTableError.
규약: 46행 · idx 1..46 연속 · upper_bound == 사다리(UPPER_BOUNDS) · 값 0.01~0.20.
"""
if not isinstance(rows, list) or len(rows) != BRACKET_COUNT:
raise BaseTableError(f"정적 테이블 행 수 불일치: {len(rows) if isinstance(rows, list) else type(rows)} != {BRACKET_COUNT}")
rates: list[int] = []
for i, row in enumerate(rows):
idx = row.get("idx")
ub = row.get("upper_bound")
av = row.get("anchoring_value")
if idx != i + 1:
raise BaseTableError(f"idx 불연속: 위치 {i} 의 idx={idx} (기대 {i + 1})")
if ub != UPPER_BOUNDS[i]:
raise BaseTableError(f"upper_bound 사다리 불일치: idx={idx} upper_bound={ub} (기대 {UPPER_BOUNDS[i]})")
if not isinstance(av, (int, float)) or av != av or not (0.01 <= av <= 0.20):
raise BaseTableError(f"anchoring_value 범위 밖: idx={idx} value={av}")
rates.append(int(round(av * 1000)))
return rates
def load_base_table() -> None:
"""리소스 파일 로드 + 검증. 최초 1회 호출(멱등)."""
global _rates
if _rates is not None:
return
try:
rows = json.loads(_RESOURCE.read_text())
except Exception as ex:
raise BaseTableError(f"정적 테이블 파일 로드 실패: {_RESOURCE}: {ex}") from ex
_rates = _validate(rows)
def get_base_rate_permille(bracket_index: int) -> int:
"""구간 인덱스 → 시작 앵커링 값(‰)."""
if _rates is None:
load_base_table()
return _rates[bracket_index]

View File

@ -0,0 +1,33 @@
"""앵커링 도메인 상수 — 읽기 경로에 필요한 부분만 원본에서 발췌 이식.
원본: schedules/anchoring/src/anchoring/constants.py (규범: 같은 폴더 docs/개발용.md §3).
조정폭(δ)·배치 주기 등 배치 전용 상수는 이식하지 않았다.
상수 변경은 정책 재확정 사안 — 코드에서 임의 조정 금지, 변경 시 원본과 동시 반영.
"""
# ── 앵커링 값(정수 천분율 ‰) ──────────────────────────────
ANCHOR_RATE_MIN = 10 # 하한 1%
ANCHOR_RATE_MAX = 200 # 상한 20%
# 시작값은 상수가 아니라 정적 테이블(base_table)에서 로드 — 0.01/10 하드코딩 금지
# ── 가격구간 (자릿수 계단식 사다리 — 폭 = 구간 상한의 10% = 선행 자릿수 밴드) ──
# 예: 1,000~1만 은 1,000원 폭(1천 원대·2천 원대…), 1만~10만 은 1만 폭(1만 원대·2만 원대…).
# 최하단(0~1,000원)은 한 칸으로 통일. 1억 초과는 마지막 칸으로 클램프.
PRICE_MAX = 100_000_000 # 정적 테이블 상한(1억)
_DECADE_STARTS = (1_000, 10_000, 100_000, 1_000_000, 10_000_000)
def _build_upper_bounds() -> tuple:
bounds = [1_000] # idx 0: [0, 1,000) 통일 칸
for start in _DECADE_STARTS: # 각 자릿수: 폭 = start (상한의 10%)
bounds.extend(start + start * i for i in range(1, 10))
return tuple(bounds) # 마지막 = 100,000,000
UPPER_BOUNDS = _build_upper_bounds() # 46개 — 구간 = [이전 upper_bound, upper_bound) 좌폐우개
BRACKET_COUNT = len(UPPER_BOUNDS) # 46
BRACKET_INDEX_MAX = BRACKET_COUNT - 1 # 45
# 칸을 구성할 수 있는 협력사 유형 코드 — common.enums.SupplierType 의 유통(1)/제조(2)/총판(3).
# 이 외(NONE=0/NULL)는 칸 해석 불가 → 정적 테이블 시작값 사용(배치 집계에서도 자동 제외).
SAMPLEABLE_SUPPLIER_TYPES = (1, 2, 3)

View File

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

View File

@ -0,0 +1,48 @@
[
{ "idx": 1, "upper_bound": 1000, "anchoring_value": 0.01 },
{ "idx": 2, "upper_bound": 2000, "anchoring_value": 0.01 },
{ "idx": 3, "upper_bound": 3000, "anchoring_value": 0.01 },
{ "idx": 4, "upper_bound": 4000, "anchoring_value": 0.01 },
{ "idx": 5, "upper_bound": 5000, "anchoring_value": 0.01 },
{ "idx": 6, "upper_bound": 6000, "anchoring_value": 0.01 },
{ "idx": 7, "upper_bound": 7000, "anchoring_value": 0.01 },
{ "idx": 8, "upper_bound": 8000, "anchoring_value": 0.01 },
{ "idx": 9, "upper_bound": 9000, "anchoring_value": 0.01 },
{ "idx": 10, "upper_bound": 10000, "anchoring_value": 0.01 },
{ "idx": 11, "upper_bound": 20000, "anchoring_value": 0.01 },
{ "idx": 12, "upper_bound": 30000, "anchoring_value": 0.01 },
{ "idx": 13, "upper_bound": 40000, "anchoring_value": 0.01 },
{ "idx": 14, "upper_bound": 50000, "anchoring_value": 0.01 },
{ "idx": 15, "upper_bound": 60000, "anchoring_value": 0.01 },
{ "idx": 16, "upper_bound": 70000, "anchoring_value": 0.01 },
{ "idx": 17, "upper_bound": 80000, "anchoring_value": 0.01 },
{ "idx": 18, "upper_bound": 90000, "anchoring_value": 0.01 },
{ "idx": 19, "upper_bound": 100000, "anchoring_value": 0.01 },
{ "idx": 20, "upper_bound": 200000, "anchoring_value": 0.01 },
{ "idx": 21, "upper_bound": 300000, "anchoring_value": 0.01 },
{ "idx": 22, "upper_bound": 400000, "anchoring_value": 0.01 },
{ "idx": 23, "upper_bound": 500000, "anchoring_value": 0.01 },
{ "idx": 24, "upper_bound": 600000, "anchoring_value": 0.01 },
{ "idx": 25, "upper_bound": 700000, "anchoring_value": 0.01 },
{ "idx": 26, "upper_bound": 800000, "anchoring_value": 0.01 },
{ "idx": 27, "upper_bound": 900000, "anchoring_value": 0.01 },
{ "idx": 28, "upper_bound": 1000000, "anchoring_value": 0.01 },
{ "idx": 29, "upper_bound": 2000000, "anchoring_value": 0.01 },
{ "idx": 30, "upper_bound": 3000000, "anchoring_value": 0.01 },
{ "idx": 31, "upper_bound": 4000000, "anchoring_value": 0.01 },
{ "idx": 32, "upper_bound": 5000000, "anchoring_value": 0.01 },
{ "idx": 33, "upper_bound": 6000000, "anchoring_value": 0.01 },
{ "idx": 34, "upper_bound": 7000000, "anchoring_value": 0.01 },
{ "idx": 35, "upper_bound": 8000000, "anchoring_value": 0.01 },
{ "idx": 36, "upper_bound": 9000000, "anchoring_value": 0.01 },
{ "idx": 37, "upper_bound": 10000000, "anchoring_value": 0.01 },
{ "idx": 38, "upper_bound": 20000000, "anchoring_value": 0.01 },
{ "idx": 39, "upper_bound": 30000000, "anchoring_value": 0.01 },
{ "idx": 40, "upper_bound": 40000000, "anchoring_value": 0.01 },
{ "idx": 41, "upper_bound": 50000000, "anchoring_value": 0.01 },
{ "idx": 42, "upper_bound": 60000000, "anchoring_value": 0.01 },
{ "idx": 43, "upper_bound": 70000000, "anchoring_value": 0.01 },
{ "idx": 44, "upper_bound": 80000000, "anchoring_value": 0.01 },
{ "idx": 45, "upper_bound": 90000000, "anchoring_value": 0.01 },
{ "idx": 46, "upper_bound": 100000000, "anchoring_value": 0.01 }
]

View File

@ -0,0 +1,21 @@
"""순수 계산 함수 — DB 접근 없음. 원본: schedules/anchoring/src/anchoring/service.py 에서
읽기 경로(칸 해석·앵커가 산출) 두 함수만 발췌 이식. 표본 판정·평가 함수는 배치 전용이라 제외.
모든 산술은 정수(천분율 ‰). float 금지 — 1원 단위 내림의 정확성 보장.
"""
from bisect import bisect_right
from common.anchoring.constants import BRACKET_INDEX_MAX, UPPER_BOUNDS
def calc_bracket_index(target_price: int) -> int:
"""목표가 → 가격구간 인덱스(0-기반). 자릿수 계단식 사다리.
좌폐우개 [이전 ub, ub): 가격이 upper_bound 와 정확히 같으면 다음 칸.
1억 이상은 마지막 인덱스로 클램프. 정적 테이블 idx = 반환값 + 1"""
return min(bisect_right(UPPER_BOUNDS, target_price), BRACKET_INDEX_MAX)
def calc_anchor_price(target_price: int, rate_permille: int) -> int:
"""앵커링가 = 목표가 × (1 − A), 1원 단위 내림. (정수 연산만 — float 곱셈 재도입 금지)"""
return target_price * (1000 - rate_permille) // 1000

View File

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

View File

@ -39,6 +39,10 @@ class IQuotationCRUD(ABC):
async def get_item_prices(self, cdb: AsyncSession, item_ids) -> Tuple[ErrorType, dict]:
pass
@abstractmethod
async def get_item_companies(self, cdb: AsyncSession, item_ids) -> Tuple[ErrorType, dict]:
pass
@abstractmethod
async def get_last_supplier_type(self, cdb: AsyncSession, supplier_id, company_id=None) -> Tuple[ErrorType, Optional[tuple]]:
pass
@ -376,6 +380,22 @@ class QuotationCRUD(IQuotationCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, {}
async def get_item_companies(self, cdb: AsyncSession, item_ids) -> Tuple[ErrorType, dict]:
"""item_id -> company_id(소유 회사) 매핑. 앵커링 칸(회사×유형×가격구간) 해석 입력."""
try:
if not item_ids:
return ErrorType.SUCCESS, {}
query = select(items.item_id, items.company_id).where(
items.item_id.in_(item_ids), items.deleted == False # noqa: E712
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
if err_type != ErrorType.SUCCESS:
return err_type, {}
return ErrorType.SUCCESS, {r[0]: r[1] for r in rows}
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, {}
async def get_last_supplier_type(self, cdb: AsyncSession, supplier_id, company_id=None) -> Tuple[ErrorType, Optional[tuple]]:
"""협력사의 직전 견적 supplier_type. (supplier_type, qt_number) | None.
sessions(supplier_id) ⨝ quotations 에서 supplier_type 가 있는 최신 견적 1건."""

View File

@ -5,6 +5,13 @@ from typing import Optional
from fastapi import Depends
from common.anchoring import (
SAMPLEABLE_SUPPLIER_TYPES,
calc_anchor_price,
calc_bracket_index,
fetch_current_rates,
get_base_rate_permille,
)
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.enums import CloseOutcome, CloseReason, DBWRType, ErrorType, NotificationType, PriceGateAction, QuotationStatus, QuotationType, SessionStatus
@ -324,8 +331,9 @@ class QuotationService:
lambda s: self.quotation_crud.list_sessions(s, original_qt_id),
)
item_ids = list({r.item_id for r in rows}) if err_type == ErrorType.SUCCESS else []
# 재생성은 목표가/앵커링가를 재계산하지 않고 직전 라운드 세션 값을 그대로 상속(KTC 방식).
inherited = {r.item_id: (r.target_price, r.target_anchoring_price) for r in rows} if err_type == ErrorType.SUCCESS else {}
# 재생성은 목표가를 재계산하지 않고 직전 라운드 세션 값을 그대로 상속(KTC 방식).
# 앵커링가는 상속하지 않는다 — 생성 시점의 칸 rate 로 항상 재계산·박제(앵커링 v1.2 인수인계 규칙 1).
inherited = {r.item_id: r.target_price for r in rows} if err_type == ErrorType.SUCCESS else {}
# 2) 타입 결정: 공급사 1곳 → 재협상 / 여러 곳 → 재견적
next_type = QuotationType.RENEGO.value if len(supplier_ids) <= 1 else QuotationType.REQUOTE.value
@ -367,7 +375,7 @@ class QuotationService:
item_ids=item_ids,
supplier_ids=list(supplier_ids),
card_ids=[], # 새 버전 안 만듦(원본 version_id 재사용)
inherited=inherited, # 직전 라운드 목표가·앵커링가 상속(재계산 안 함)
inherited=inherited, # 직전 라운드 목표가 상속(앵커링가는 현재 rate 로 재계산)
)
async def _build_quotation(
@ -376,7 +384,7 @@ class QuotationService:
type_: int, status: int, round_: int, start_time, end_time,
manager_name, manager_email, manager_contact_number, memo, md_price, supplier_type,
item_ids: list, supplier_ids: list, card_ids: list,
inherited: Optional[dict] = None, # 재생성 시 {item_id: (target_price, target_anchoring_price)} 상속(KTC) — 있으면 재계산 안 함
inherited: Optional[dict] = None, # 재생성 시 {item_id: target_price} 상속(KTC) — 목표가만. 앵커는 항상 재계산
) -> Res_CreateQuotation:
"""견적 1건 + (상품×공급사) 세션들을 한 트랜잭션으로 생성하는 공통 빌더."""
res = Res_CreateQuotation()
@ -398,7 +406,8 @@ class QuotationService:
rates = rates if _err == ErrorType.SUCCESS else {}
fee = self.INTERNET_AVERAGE_FEE # 인터넷가 차감 수수료율(상수)
margin = rates.get("margin") or 0.0 # 판매가 차감 목표마진율
anchoring = rates.get("anchoring") or 0.0 # 앵커링가 = 목표가×(1−값)
# 앵커링가는 quotation_settings.anchoring_value 를 더 이상 쓰지 않는다(앵커링 v1.2) —
# 칸(회사×협력사유형×가격구간)별 조정 rate 로 계산한다. 아래 세션 생성부 ②.
# 선택 협상카드가 있으면 새 버전을 만들어 카드들을 묶고, quotation.version_id 로 연결한다.
# (quotation↔card 는 version → version_nego_cards/version_wild_cards 로 연결.)
@ -451,33 +460,16 @@ class QuotationService:
# 상품 × 공급사 조합마다 세션 1개. md/매입/판매/인터넷 후보가 하나도 없으면 목표가 산정 불가 → 생성 실패.
# 신규(NEW_NEGO/NEW_QUOTE)는 인터넷최저가만, 재(RENEGO/REQUOTE)는 매입가·판매가까지 후보(KTC 신규/재 분리).
is_new = QuotationType.is_new(type_)
session_objs = []
# ① 목표가 산정 — 재생성(inherited)은 직전 라운드 값 그대로 상속(KTC), 그 외엔 후보 min.
target_prices = {}
try:
for iid in item_ids:
if inherited and iid in inherited:
tp, ap = inherited[iid] # 재생성: 직전 라운드 목표가·앵커링가 그대로 상속(KTC) — 재계산 안 함
target_prices[iid] = inherited[iid]
else:
internet, purchase, selling = prices.get(iid) or (None, None, None)
tp = self._calc_target_price(md_price, internet, purchase, selling, fee, margin, is_new=is_new)
if not 0.0 <= anchoring < 1.0: # 율 1 이상이면 앵커링가가 0/음수 → 설정 오류로 막는다.
raise ValueError(f"앵커링 값은 0 이상 1 미만이어야 합니다: anchoring={anchoring}")
ap = int(tp * (1 - anchoring)) # 앵커링가 = floor(목표가×(1−앵커링율)); 율 0이면 목표가와 동일
for sid in supplier_ids:
session_objs.append(
sessions(
session_id=uuid.uuid4(),
quotation_id=qt_id,
item_id=iid,
supplier_id=sid,
qt_number=quotation.number,
qt_round=quotation.round,
qt_type=quotation.type,
target_price=tp,
target_anchoring_price=ap,
status=SessionStatus.CREATED.value,
end_time=quotation.end_time,
)
)
target_prices[iid] = self._calc_target_price(md_price, internet, purchase, selling, fee, margin, is_new=is_new)
except ValueError as ex:
LOG.w(
f"[목표가 산정불가] qt_id={qt_id} item={iid} is_new={is_new} "
@ -486,6 +478,50 @@ class QuotationService:
res.result.SetResult(ErrorType.QUOTATION_TARGET_PRICE_UNAVAILABLE)
return res
# ② 앵커가 산출 — 칸(items.company_id × quotations.supplier_type × 목표가 구간) rate 조회 후
# 정수 연산으로 박제(앵커링 v1.2, 인수인계.md §1.3). 유형 미지정/조정 이력 없음/조회 실패는
# 정적 테이블 시작값 폴백 — rate 조회 때문에 견적 생성이 실패하지 않는다(규칙 6).
_err, item_companies = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(),
DBWRType.DB_READ.value,
lambda s: self.quotation_crud.get_item_companies(s, item_ids),
)
item_companies = item_companies if _err == ErrorType.SUCCESS else {}
rate_map = {}
if supplier_type in SAMPLEABLE_SUPPLIER_TYPES and item_companies:
rate_map = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(),
DBWRType.DB_READ.value,
lambda s: fetch_current_rates(s, list(set(item_companies.values())), supplier_type),
)
session_objs = []
for iid in item_ids:
tp = target_prices[iid]
bracket = calc_bracket_index(tp)
company = item_companies.get(iid)
rate = rate_map.get((company, bracket)) if company is not None else None
if rate is None:
rate = get_base_rate_permille(bracket)
ap = calc_anchor_price(tp, rate) # 목표가×(1000−rate)//1000 — float 곱셈 금지(1원 내림 정확성)
for sid in supplier_ids:
session_objs.append(
sessions(
session_id=uuid.uuid4(),
quotation_id=qt_id,
item_id=iid,
supplier_id=sid,
qt_number=quotation.number,
qt_round=quotation.round,
qt_type=quotation.type,
target_price=tp,
target_anchoring_price=ap, # 박제 — 이후 수정 금지(협상 판정·앵커링 학습 기준값)
anchor_rate_permille=rate,
status=SessionStatus.CREATED.value,
end_time=quotation.end_time,
)
)
# 버전 → (버전-카드 매핑) → 견적 → 세션 순으로 한 트랜잭션에 insert(FK 순서 보장).
ops = []
if version_obj is not None:

View File

@ -0,0 +1,206 @@
"""앵커링 v1.2 — 견적 생성 시 칸(회사×협력사유형×가격구간) rate 로 앵커가를 박제하는지 검증.
이식 명세: schedules/anchoring/docs/인수인계.md §1.
- 앵커가 = 목표가 × (1000 − rate) // 1000 (정수 연산), anchor_rate_permille 동시 박제
- 조정 이력 없음 / 유형 미지정 / anchoring 스키마 미적용 → 정적 테이블 시작값(10‰) 폴백,
견적 생성은 실패하지 않는다(규칙 6)
- 재생성 라운드는 목표가만 상속하고 앵커는 생성 시점 rate 로 재계산(규칙 1 — 상속 폐지)
"""
import uuid
from datetime import datetime
from sqlalchemy import text
from common.anchoring import calc_bracket_index
from common.enums import QuotationType
from crud.quotation_crud import QuotationCRUD
from router.v1.quotation.protocol import Req_CreateQuotation
from services.quotation_service import QuotationService
FUTURE = datetime(2999, 1, 1) # 마감시각 미래 — 생성 직후 크론에 안 잡히게
BASE_RATE = 10 # 정적 테이블 시작값(‰) — anchoring_base.json 전 구간 0.01
async def test_create_without_anchoring_schema_falls_back_to_base_rate(db_engine, company_id):
"""검증: anchoring 스키마가 아예 없는 DB 에서 supplier_type=1(유통) 견적 생성.
기대결과: 조회 실패에도 생성 성공 + 앵커가=목표가×990‰(시작값), rate=10 박제."""
await _drop_anchoring(db_engine)
item = await _seed_item(db_engine, company_id, internet_lowest=100_000)
res = await _create(item_ids=[item], supplier_type=1)
assert res.result.success is True
tp = int(100_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE)) # 92,200
rows = await _session_anchor_rows(db_engine, res.qt_id)
assert rows == {item: (tp, tp * (1000 - BASE_RATE) // 1000, BASE_RATE)}
async def test_create_uses_latest_adjusted_rate_per_cell(db_engine, company_id):
"""검증: 한 상품의 칸에만 조정 이력(50‰)을 넣고 상품 2개(다른 가격구간)로 견적 생성.
기대결과: 이력 칸 상품은 50‰, 무이력 칸 상품은 시작값 10‰ 로 각각 박제(칸 단위 조회)."""
await _reset_anchoring(db_engine)
item_hit = await _seed_item(db_engine, company_id, internet_lowest=100_000) # tp 92,200
item_miss = await _seed_item(db_engine, company_id, internet_lowest=5_000) # tp 4,610 — 다른 구간
tp_hit = int(100_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE))
tp_miss = int(5_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE))
await _seed_adjustment(db_engine, company_id, supplier_type=1, bracket=calc_bracket_index(tp_hit), rate_after=50)
res = await _create(item_ids=[item_hit, item_miss], supplier_type=1)
assert res.result.success is True
rows = await _session_anchor_rows(db_engine, res.qt_id)
assert rows[item_hit] == (tp_hit, tp_hit * 950 // 1000, 50)
assert rows[item_miss] == (tp_miss, tp_miss * 990 // 1000, BASE_RATE)
async def test_supplier_type_unset_uses_base_rate(db_engine, company_id):
"""검증: supplier_type 미지정(None) 견적 생성 — 칸(회사×유형×구간) 구성 불가.
기대결과: 같은 회사·구간에 조정 이력이 있어도 쓰지 않고 시작값 10‰ 박제."""
await _reset_anchoring(db_engine)
item = await _seed_item(db_engine, company_id, internet_lowest=100_000)
tp = int(100_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE))
await _seed_adjustment(db_engine, company_id, supplier_type=1, bracket=calc_bracket_index(tp), rate_after=50)
res = await _create(item_ids=[item], supplier_type=None)
assert res.result.success is True
rows = await _session_anchor_rows(db_engine, res.qt_id)
assert rows == {item: (tp, tp * 990 // 1000, BASE_RATE)}
async def test_regenerate_inherits_target_but_recomputes_anchor(db_engine, company_id):
"""검증: 1라운드 생성(무이력→10‰) 후 그 칸에 조정 50‰ 을 넣고 다음 라운드 재생성.
기대결과: 목표가는 그대로 상속, 앵커는 50‰ 로 재계산 — 앵커 상속 폐지(인수인계 규칙 1)."""
await _reset_anchoring(db_engine)
item = await _seed_item(db_engine, company_id, internet_lowest=100_000)
supplier = uuid.uuid4()
res1 = await _create(item_ids=[item], supplier_type=1, supplier_ids=[supplier])
assert res1.result.success is True
tp = int(100_000 * (1 - QuotationService.INTERNET_AVERAGE_FEE))
rows1 = await _session_anchor_rows(db_engine, res1.qt_id)
assert rows1 == {item: (tp, tp * 990 // 1000, BASE_RATE)} # 1라운드는 시작값
await _seed_adjustment(db_engine, company_id, supplier_type=1, bracket=calc_bracket_index(tp), rate_after=50)
res2 = await _service().regenerate_next_round(res1.qt_id, [supplier])
assert res2.result.success is True
rows2 = await _session_anchor_rows(db_engine, res2.qt_id)
assert rows2 == {item: (tp, tp * 950 // 1000, 50)} # 목표가 상속 + 앵커만 현재 rate
def test_bracket_index_golden_vectors():
"""검증: 이식된 calc_bracket_index 경계 골든 벡터(자릿수 사다리, 좌폐우개).
배치의 박제 정합 감시는 rate↔앵커가 자기일관만 보므로 브래킷 이식 오류를 못 잡는다 —
이 벡터가 원본(schedules/anchoring)과 어긋나면 이식 오류다(값 변경 금지)."""
assert calc_bracket_index(0) == 0 # 최하단 통일 칸 [0, 1,000)
assert calc_bracket_index(999) == 0
assert calc_bracket_index(1_000) == 1 # 경계 = 다음 칸(좌폐우개)
assert calc_bracket_index(9_999) == 9
assert calc_bracket_index(10_000) == 10 # 자릿수 전환 경계
assert calc_bracket_index(99_999_999) == 45
assert calc_bracket_index(100_000_000) == 45 # 1억 이상은 마지막 칸 클램프
assert calc_bracket_index(10**12) == 45
# ===== 헬퍼 =====
def _service():
return QuotationService(QuotationCRUD())
async def _create(*, item_ids, supplier_type, supplier_ids=None):
"""supplier_type 을 지정해 견적 1건 생성(공급사 기본 1곳)."""
req = Req_CreateQuotation(
qt_setting_id=uuid.uuid4(), # FK 미설정 — 세팅 없으면 율 0(앵커는 세팅과 무관해짐)
name="앵커링검증",
type=QuotationType.NEW_QUOTE.value,
end_time=FUTURE,
supplier_type=supplier_type,
item_ids=list(item_ids),
supplier_ids=supplier_ids or [uuid.uuid4()],
)
return await _service().create_quotation(str(uuid.uuid4()), req)
async def _seed_item(engine, company_id, *, internet_lowest):
"""상품 1건 시드(인터넷최저가만). NOT NULL 컬럼은 명시(ORM default 는 raw INSERT 에 안 먹음)."""
item_id = uuid.uuid4()
async with engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO items "
"(item_id, company_id, user_id, name, category_type, "
" internet_lowest_price_yn, internet_lowest_price) VALUES "
"(:item_id, :company_id, :user_id, '상품', 1, false, :ilp)"
),
{"item_id": item_id, "company_id": uuid.UUID(company_id),
"user_id": uuid.uuid4(), "ilp": internet_lowest},
)
return item_id
async def _session_anchor_rows(engine, qt_id):
"""생성된 견적의 item_id -> (target_price, target_anchoring_price, anchor_rate_permille)."""
async with engine.begin() as conn:
rows = (await conn.execute(
text("SELECT item_id, target_price, target_anchoring_price, anchor_rate_permille "
"FROM sessions WHERE quotation_id = :qt"),
{"qt": qt_id},
)).all()
out = {}
for item_id, tp, ap, rate in rows:
assert out.setdefault(item_id, (tp, ap, rate)) == (tp, ap, rate) # 같은 상품 세션끼리 동일 박제
return out
async def _drop_anchoring(engine):
async with engine.begin() as conn:
await conn.execute(text("DROP SCHEMA IF EXISTS anchoring CASCADE"))
# 모듈 소유 DDL(schedules/anchoring/schema.sql)에서 조회 경로에 필요한 부분 발췌.
# negodata 는 이 스키마를 만들지 않는다(모듈이 소유) — 테스트 재현용으로만 여기 둔다.
_ANCHORING_DDL = (
"CREATE SCHEMA IF NOT EXISTS anchoring",
"""CREATE TABLE IF NOT EXISTS anchoring.rate_adjustments (
id BIGSERIAL PRIMARY KEY,
company_id uuid NOT NULL,
supplier_type SMALLINT NOT NULL,
price_bracket_index INTEGER NOT NULL,
nego_count INTEGER NOT NULL,
success_count INTEGER NOT NULL,
anchor_rate_before SMALLINT NOT NULL,
anchor_rate_after SMALLINT NOT NULL,
consumed_session_ids JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)""",
"""CREATE 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""",
)
async def _reset_anchoring(engine):
"""anchoring 스키마를 깨끗하게 재생성(테스트 간 조정 이력 격리 — TRUNCATE 픽스처 밖 스키마)."""
async with engine.begin() as conn:
await conn.execute(text("DROP SCHEMA IF EXISTS anchoring CASCADE"))
for ddl in _ANCHORING_DDL:
await conn.execute(text(ddl))
async def _seed_adjustment(engine, company_id, *, supplier_type, bracket, rate_after):
"""칸에 조정 이력 1행 삽입(배치가 쌓는 행의 최소 재현)."""
async with engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO anchoring.rate_adjustments "
"(company_id, supplier_type, price_bracket_index, nego_count, success_count, "
" anchor_rate_before, anchor_rate_after, consumed_session_ids) "
"VALUES (:cid, :stype, :bracket, 10, 8, 10, :after, '[]'::jsonb)"
),
{"cid": uuid.UUID(company_id), "stype": supplier_type, "bracket": bracket, "after": rate_after},
)