diff --git a/negodata/backend/common/anchoring/__init__.py b/negodata/backend/common/anchoring/__init__.py new file mode 100644 index 0000000..35efef4 --- /dev/null +++ b/negodata/backend/common/anchoring/__init__.py @@ -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", +] diff --git a/negodata/backend/common/anchoring/base_table.py b/negodata/backend/common/anchoring/base_table.py new file mode 100644 index 0000000..92c64d4 --- /dev/null +++ b/negodata/backend/common/anchoring/base_table.py @@ -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] diff --git a/negodata/backend/common/anchoring/constants.py b/negodata/backend/common/anchoring/constants.py new file mode 100644 index 0000000..7e7b96e --- /dev/null +++ b/negodata/backend/common/anchoring/constants.py @@ -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) diff --git a/negodata/backend/common/anchoring/reader.py b/negodata/backend/common/anchoring/reader.py new file mode 100644 index 0000000..5df3fc7 --- /dev/null +++ b/negodata/backend/common/anchoring/reader.py @@ -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 diff --git a/negodata/backend/common/anchoring/resources/anchoring_base.json b/negodata/backend/common/anchoring/resources/anchoring_base.json new file mode 100644 index 0000000..9ae1e85 --- /dev/null +++ b/negodata/backend/common/anchoring/resources/anchoring_base.json @@ -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 } +] diff --git a/negodata/backend/common/anchoring/service.py b/negodata/backend/common/anchoring/service.py new file mode 100644 index 0000000..b5ddae5 --- /dev/null +++ b/negodata/backend/common/anchoring/service.py @@ -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 diff --git a/negodata/backend/common/database/model/models.py b/negodata/backend/common/database/model/models.py index 4d989c2..349fdc7 100644 --- a/negodata/backend/common/database/model/models.py +++ b/negodata/backend/common/database/model/models.py @@ -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) # 입찰 시각 diff --git a/negodata/backend/crud/quotation_crud.py b/negodata/backend/crud/quotation_crud.py index eab039f..0e77377 100644 --- a/negodata/backend/crud/quotation_crud.py +++ b/negodata/backend/crud/quotation_crud.py @@ -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건.""" diff --git a/negodata/backend/services/quotation_service.py b/negodata/backend/services/quotation_service.py index b282815..927990b 100644 --- a/negodata/backend/services/quotation_service.py +++ b/negodata/backend/services/quotation_service.py @@ -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: diff --git a/negodata/backend/tests/test_quotation_anchoring.py b/negodata/backend/tests/test_quotation_anchoring.py new file mode 100644 index 0000000..885a062 --- /dev/null +++ b/negodata/backend/tests/test_quotation_anchoring.py @@ -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}, + )