"""견적 생성 — 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 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, price=100_000): """상품 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, " " price, internet_lowest_price_yn, internet_lowest_price) VALUES " "(:item_id, :company_id, :user_id, '상품', 1, :price, false, :ilp)" ), {"item_id": item_id, "company_id": uuid.UUID(company_id), "user_id": uuid.uuid4(), "price": price, "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