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:
parent
c98e5fa7bb
commit
b362edf0d7
22
negodata/backend/common/anchoring/__init__.py
Normal file
22
negodata/backend/common/anchoring/__init__.py
Normal 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",
|
||||
]
|
||||
59
negodata/backend/common/anchoring/base_table.py
Normal file
59
negodata/backend/common/anchoring/base_table.py
Normal 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]
|
||||
33
negodata/backend/common/anchoring/constants.py
Normal file
33
negodata/backend/common/anchoring/constants.py
Normal 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)
|
||||
55
negodata/backend/common/anchoring/reader.py
Normal file
55
negodata/backend/common/anchoring/reader.py
Normal 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
|
||||
@ -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 }
|
||||
]
|
||||
21
negodata/backend/common/anchoring/service.py
Normal file
21
negodata/backend/common/anchoring/service.py
Normal 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
|
||||
@ -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) # 입찰 시각
|
||||
|
||||
@ -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건."""
|
||||
|
||||
@ -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:
|
||||
|
||||
206
negodata/backend/tests/test_quotation_anchoring.py
Normal file
206
negodata/backend/tests/test_quotation_anchoring.py
Normal 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},
|
||||
)
|
||||
Loading…
Reference in New Issue
Block a user