o2o-negosium-original/negodata/backend/tests/test_quotation_anchoring.py

227 lines
11 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 — 견적 생성 시 칸(회사×상품-협력사 공급유형×가격구간) 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},
)