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