o2o-negosium-original/negodata/backend/tests/test_quotation_anchoring.py
민헌 b362edf0d7 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>
2026-07-04 16:37:27 +09:00

207 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""앵커링 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},
)