227 lines
11 KiB
Python
227 lines
11 KiB
Python
"""앵커링 v1.2 — 견적 생성 시 칸(회사×상품-협력사 공급유형×가격구간) anchoring_value 로 앵커가를 박제하는지 검증.
|
||
|
||
이식 명세: schedules/anchoring/docs/인수인계.md §1.
|
||
- 앵커가 = 목표가 × (1000 − anchoring_value), 10원 반올림(calc_anchoring_price), anchoring_value 동시 박제
|
||
- 조정 이력 없음 / 매핑 유형 미지정 / anchoring 스키마 미적용 → 정적 테이블 시작값(10‰) 폴백,
|
||
견적 생성은 실패하지 않는다(규칙 6)
|
||
- 재생성 라운드는 목표가만 상속하고 앵커는 생성 시점 anchoring_value 로 재계산(규칙 1 — 상속 폐지)
|
||
"""
|
||
import uuid
|
||
from datetime import datetime
|
||
|
||
from sqlalchemy import text
|
||
|
||
from common.anchoring import calc_anchoring_price, calc_price_range_index
|
||
from common.enums import QuotationType
|
||
from crud.quotation_crud import QuotationCRUD
|
||
from router.v1.quotation.protocol import Req_CreateQuotation
|
||
from services.quotation import QuotationService
|
||
|
||
FUTURE = datetime(2999, 1, 1) # 마감시각 미래 — 생성 직후 크론에 안 잡히게
|
||
BASE_VALUE = 10 # 정적 테이블 시작값(‰) — anchoring_base.json 전 구간 0.01
|
||
|
||
|
||
async def test_create_without_anchoring_schema_falls_back_to_base_value(db_engine, company_id):
|
||
"""검증: anchoring 스키마가 아예 없는 DB 에서 supply_type=1(유통) 매핑으로 견적 생성.
|
||
기대결과: 조회 실패에도 생성 성공 + 앵커가=목표가×990‰(시작값), anchoring_value=10 박제."""
|
||
await _drop_anchoring(db_engine)
|
||
item = await _seed_item(db_engine, company_id, internet_lowest=100_000)
|
||
|
||
res = await _create(db_engine, item_ids=[item], supply_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, calc_anchoring_price(tp, BASE_VALUE), BASE_VALUE)}
|
||
|
||
|
||
async def test_create_uses_latest_adjusted_value_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, price_range=calc_price_range_index(tp_hit), value_after=50)
|
||
|
||
res = await _create(db_engine, item_ids=[item_hit, item_miss], supply_type=1)
|
||
|
||
assert res.result.success is True
|
||
rows = await _session_anchor_rows(db_engine, res.qt_id)
|
||
assert rows[item_hit] == (tp_hit, calc_anchoring_price(tp_hit, 50), 50)
|
||
assert rows[item_miss] == (tp_miss, calc_anchoring_price(tp_miss, BASE_VALUE), BASE_VALUE)
|
||
|
||
|
||
async def test_supply_type_unset_uses_base_value(db_engine, company_id):
|
||
"""검증: supply_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, price_range=calc_price_range_index(tp), value_after=50)
|
||
|
||
res = await _create(db_engine, item_ids=[item], supply_type=None)
|
||
|
||
assert res.result.success is True
|
||
rows = await _session_anchor_rows(db_engine, res.qt_id)
|
||
assert rows == {item: (tp, calc_anchoring_price(tp, BASE_VALUE), BASE_VALUE)}
|
||
|
||
|
||
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(db_engine, item_ids=[item], supply_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, calc_anchoring_price(tp, BASE_VALUE), BASE_VALUE)} # 1라운드는 시작값
|
||
|
||
await _seed_adjustment(db_engine, company_id, supplier_type=1, price_range=calc_price_range_index(tp), value_after=50)
|
||
res2 = await _service().regenerate_next_round(res1.qt_id, [supplier])
|
||
|
||
assert res2.result.success is True
|
||
rows2 = await _session_anchor_rows(db_engine, res2.qt_id)
|
||
assert rows2 == {item: (tp, calc_anchoring_price(tp, 50), 50)} # 목표가 상속 + 앵커만 현재 anchoring_value
|
||
|
||
|
||
def test_price_range_index_golden_vectors():
|
||
"""검증: 이식된 calc_price_range_index 경계 골든 벡터(자릿수 사다리, 좌폐우개).
|
||
배치의 박제 정합 감시는 값↔앵커가 자기일관만 보므로 브래킷 이식 오류를 못 잡는다 —
|
||
이 벡터가 원본(schedules/anchoring)과 어긋나면 이식 오류다(값 변경 금지)."""
|
||
assert calc_price_range_index(0) == 0 # 최하단 통일 칸 [0, 1,000)
|
||
assert calc_price_range_index(999) == 0
|
||
assert calc_price_range_index(1_000) == 1 # 경계 = 다음 칸(좌폐우개)
|
||
assert calc_price_range_index(9_999) == 9
|
||
assert calc_price_range_index(10_000) == 10 # 자릿수 전환 경계
|
||
assert calc_price_range_index(99_999_999) == 45
|
||
assert calc_price_range_index(100_000_000) == 45 # 1억 이상은 마지막 칸 클램프
|
||
assert calc_price_range_index(10**12) == 45
|
||
|
||
|
||
# ===== 헬퍼 =====
|
||
def _service():
|
||
return QuotationService(QuotationCRUD())
|
||
|
||
|
||
async def _create(engine, *, item_ids, supply_type, supplier_ids=None):
|
||
"""supplier_items.supply_type 매핑을 시드한 뒤 견적 1건 생성(공급사 기본 1곳)."""
|
||
supplier_ids = supplier_ids or [uuid.uuid4()]
|
||
await _seed_supplier_items(engine, item_ids, supplier_ids, supply_type=supply_type)
|
||
req = Req_CreateQuotation(
|
||
qt_setting_id=uuid.uuid4(), # FK 미설정 — 세팅 없으면 율 0(앵커는 세팅과 무관해짐)
|
||
name="앵커링검증",
|
||
type=QuotationType.NEW_QUOTE.value,
|
||
end_time=FUTURE,
|
||
item_ids=list(item_ids),
|
||
supplier_ids=supplier_ids,
|
||
)
|
||
return await _service().create_quotation(str(uuid.uuid4()), req)
|
||
|
||
|
||
async def _seed_supplier_items(engine, item_ids, supplier_ids, *, supply_type):
|
||
async with engine.begin() as conn:
|
||
for item_id in item_ids:
|
||
for supplier_id in supplier_ids:
|
||
await conn.execute(
|
||
text(
|
||
"INSERT INTO supplier_items "
|
||
"(supplier_item_id, supplier_id, item_id, supply_type) "
|
||
"VALUES (:id, :supplier_id, :item_id, :supply_type)"
|
||
),
|
||
{
|
||
"id": uuid.uuid4(),
|
||
"supplier_id": supplier_id,
|
||
"item_id": item_id,
|
||
"supply_type": supply_type if supply_type is not None else 0,
|
||
},
|
||
)
|
||
|
||
|
||
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, anchoring_price, anchoring_value)."""
|
||
async with engine.begin() as conn:
|
||
rows = (await conn.execute(
|
||
text("SELECT item_id, target_price, anchoring_price, anchoring_value "
|
||
"FROM sessions WHERE quotation_id = :qt"),
|
||
{"qt": qt_id},
|
||
)).all()
|
||
out = {}
|
||
for item_id, tp, ap, value in rows:
|
||
assert out.setdefault(item_id, (tp, ap, value)) == (tp, ap, value) # 같은 상품 세션끼리 동일 박제
|
||
return out
|
||
|
||
|
||
async def _drop_anchoring(engine):
|
||
async with engine.begin() as conn:
|
||
await conn.execute(text("DROP SCHEMA IF EXISTS anchoring CASCADE"))
|
||
|
||
|
||
# 모듈 소유 DDL(postgres-init/05-anchoring-schema.sql)에서 조회 경로에 필요한 부분 발췌.
|
||
# negodata 는 이 스키마를 만들지 않는다(모듈이 소유) — 테스트 재현용으로만 여기 둔다.
|
||
_ANCHORING_DDL = (
|
||
"CREATE SCHEMA IF NOT EXISTS anchoring",
|
||
"""CREATE TABLE IF NOT EXISTS anchoring.adjustments (
|
||
adjustment_id BIGSERIAL PRIMARY KEY,
|
||
company_id uuid NOT NULL,
|
||
supplier_type SMALLINT NOT NULL,
|
||
price_range_index INTEGER NOT NULL,
|
||
sample_count INTEGER NOT NULL,
|
||
success_count INTEGER NOT NULL,
|
||
anchoring_value_before SMALLINT NOT NULL,
|
||
anchoring_value_after SMALLINT NOT NULL,
|
||
used_session_ids JSONB NOT NULL,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
)""",
|
||
"""CREATE OR REPLACE VIEW anchoring.current_values AS
|
||
SELECT DISTINCT ON (company_id, supplier_type, price_range_index)
|
||
company_id, supplier_type, price_range_index,
|
||
anchoring_value_after AS anchoring_value,
|
||
adjustment_id AS last_adjustment_id, created_at AS last_adjusted_at
|
||
FROM anchoring.adjustments
|
||
ORDER BY company_id, supplier_type, price_range_index, adjustment_id DESC""",
|
||
)
|
||
|
||
|
||
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, price_range, value_after):
|
||
"""칸에 조정 이력 1행 삽입(배치가 쌓는 행의 최소 재현)."""
|
||
async with engine.begin() as conn:
|
||
await conn.execute(
|
||
text(
|
||
"INSERT INTO anchoring.adjustments "
|
||
"(company_id, supplier_type, price_range_index, sample_count, success_count, "
|
||
" anchoring_value_before, anchoring_value_after, used_session_ids) "
|
||
"VALUES (:cid, :stype, :price_range, 10, 8, 10, :after, '[]'::jsonb)"
|
||
),
|
||
{"cid": uuid.UUID(company_id), "stype": supplier_type, "price_range": price_range, "after": value_after},
|
||
)
|