155 lines
6.7 KiB
Python
155 lines
6.7 KiB
Python
"""견적 생성 — item×supplier 조합마다 세션이 생기고, 목표가가 산정되는지 검증.
|
||
|
||
기존 test_features.test_quotation_create 는 item/supplier 없이 '세션 0건' 경로만 본다.
|
||
여기선 상품(인터넷최저가)을 시드해 세션 생성 + 목표가 계산(신규=인터넷최저가×(1−수수료))까지 본다.
|
||
서비스(create_quotation)를 직접 호출한다 — HTTP/auth 경로(현재 /v1/auth/create 미존재)를 안 타고 생성 로직만 격리.
|
||
"""
|
||
import uuid
|
||
from datetime import datetime
|
||
|
||
from sqlalchemy import text
|
||
|
||
from common.enums import PriceGateAction, 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) # 마감시각 미래 — 생성 직후 크론에 안 잡히게
|
||
|
||
|
||
async def test_create_builds_sessions_with_target_price(db_engine, company_id):
|
||
"""검증: 신규견적을 상품2×공급사2로 생성.
|
||
기대결과: success=True, 세션 4개, 각 목표가 = int(인터넷최저가 × (1−0.078))."""
|
||
item1 = await _seed_item(db_engine, company_id, internet_lowest=100_000)
|
||
item2 = await _seed_item(db_engine, company_id, internet_lowest=50_000)
|
||
suppliers = [uuid.uuid4(), uuid.uuid4()]
|
||
|
||
req = Req_CreateQuotation(
|
||
qt_setting_id=uuid.uuid4(), # FK 미설정 — 세팅 없으면 율 0(신규는 인터넷최저가만 쓰므로 무관)
|
||
name="신규견적A",
|
||
type=QuotationType.NEW_QUOTE.value,
|
||
end_time=FUTURE,
|
||
item_ids=[item1, item2],
|
||
supplier_ids=suppliers,
|
||
)
|
||
res = await _service().create_quotation(str(uuid.uuid4()), req)
|
||
|
||
assert res.result.success is True
|
||
assert res.session_count == 4 # 상품 2 × 공급사 2
|
||
|
||
fee = QuotationService.INTERNET_AVERAGE_FEE
|
||
expected = {item1: int(100_000 * (1 - fee)), item2: int(50_000 * (1 - fee))}
|
||
rows = await _session_target_prices(db_engine, res.qt_id)
|
||
assert len(rows) == 4
|
||
for item_id, target_price in rows:
|
||
assert target_price == expected[item_id] # 상품별 목표가가 공급사 수만큼 동일
|
||
|
||
|
||
async def test_create_without_price_fails(db_engine, company_id):
|
||
"""검증: 가격 후보(인터넷최저가·md 등)가 전무한 상품으로 견적 생성.
|
||
기대결과: 목표가 산정 불가로 success=False, 세션 0건(미생성)."""
|
||
item = await _seed_item(db_engine, company_id, internet_lowest=None)
|
||
|
||
req = Req_CreateQuotation(
|
||
qt_setting_id=uuid.uuid4(),
|
||
name="가격없음",
|
||
type=QuotationType.NEW_QUOTE.value,
|
||
end_time=FUTURE,
|
||
item_ids=[item],
|
||
supplier_ids=[uuid.uuid4()],
|
||
)
|
||
res = await _service().create_quotation(str(uuid.uuid4()), req)
|
||
|
||
assert res.result.success is False # QUOTATION_TARGET_PRICE_UNAVAILABLE
|
||
rows = await _session_target_prices(db_engine, res.qt_id) if res.qt_id else []
|
||
assert rows == []
|
||
|
||
|
||
async def test_auction_forces_lowest_price_award(db_engine, company_id):
|
||
"""검증: 1:N 경매(NEW_QUOTE)에 낙찰 기준(OPEN/OPEN)을 실어 생성 요청.
|
||
기대결과: 경매는 무조건 최저가 낙찰 — 견적 행에 mid=over=AWARD 로 강제 저장(요청값 무시)."""
|
||
item = await _seed_item(db_engine, company_id, internet_lowest=100_000)
|
||
|
||
req = Req_CreateQuotation(
|
||
qt_setting_id=uuid.uuid4(),
|
||
name="경매정책강제",
|
||
type=QuotationType.NEW_QUOTE.value,
|
||
end_time=FUTURE,
|
||
item_ids=[item],
|
||
supplier_ids=[uuid.uuid4(), uuid.uuid4()],
|
||
mid_action=PriceGateAction.OPEN.value, # 경매엔 의미 없음 — 서버가 덮어야 함
|
||
over_action=PriceGateAction.OPEN.value,
|
||
)
|
||
res = await _service().create_quotation(str(uuid.uuid4()), req)
|
||
|
||
assert res.result.success is True
|
||
mid, over = await _quotation_policy(db_engine, res.qt_id)
|
||
assert mid == PriceGateAction.AWARD.value, f"경매 mid_action 은 AWARD 강제여야 함, 실제 {mid}"
|
||
assert over == PriceGateAction.AWARD.value, f"경매 over_action 은 AWARD 강제여야 함, 실제 {over}"
|
||
|
||
|
||
async def test_nego_persists_award_criterion(db_engine, company_id):
|
||
"""검증: 1:1 협상(NEW_NEGO)에 낙찰 기준(mid=AWARD/over=OPEN=목표까지 낙찰)을 실어 생성.
|
||
기대결과: 요청값이 견적 행에 그대로 박제(협상은 사용자가 낙찰 기준을 정한다. 목표초과=개찰)."""
|
||
item = await _seed_item(db_engine, company_id, internet_lowest=100_000)
|
||
|
||
req = Req_CreateQuotation(
|
||
qt_setting_id=uuid.uuid4(),
|
||
name="협상정책박제",
|
||
type=QuotationType.NEW_NEGO.value,
|
||
end_time=FUTURE,
|
||
item_ids=[item],
|
||
supplier_ids=[uuid.uuid4()],
|
||
mid_action=PriceGateAction.AWARD.value,
|
||
over_action=PriceGateAction.OPEN.value,
|
||
)
|
||
res = await _service().create_quotation(str(uuid.uuid4()), req)
|
||
|
||
assert res.result.success is True
|
||
mid, over = await _quotation_policy(db_engine, res.qt_id)
|
||
assert (mid, over) == (
|
||
PriceGateAction.AWARD.value, PriceGateAction.OPEN.value,
|
||
), f"협상 낙찰 기준이 그대로 저장돼야 함, 실제 {(mid, over)}"
|
||
|
||
|
||
# ===== 헬퍼 (위 테스트들이 쓰는 도우미) =====
|
||
def _service():
|
||
return QuotationService(QuotationCRUD())
|
||
|
||
|
||
async def _quotation_policy(engine, qt_id):
|
||
"""생성된 견적의 낙찰 기준 (mid_action, over_action)."""
|
||
async with engine.begin() as conn:
|
||
return (await conn.execute(
|
||
text("SELECT mid_action, over_action FROM quotations WHERE qt_id = :qt"),
|
||
{"qt": qt_id},
|
||
)).one()
|
||
|
||
|
||
async def _seed_item(engine, company_id, *, internet_lowest):
|
||
"""상품 1건 시드(인터넷최저가만). category_type·internet_lowest_price_yn 은 NOT NULL —
|
||
ORM default 는 raw INSERT 에 안 먹으므로 명시한다(conftest companies.status 와 같은 이유)."""
|
||
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_target_prices(engine, qt_id):
|
||
"""생성된 견적의 (item_id -> target_price) 매핑."""
|
||
async with engine.begin() as conn:
|
||
rows = (await conn.execute(
|
||
text("SELECT item_id, target_price FROM sessions WHERE quotation_id = :qt"),
|
||
{"qt": qt_id},
|
||
)).all()
|
||
return rows
|