192 lines
9.8 KiB
Python
192 lines
9.8 KiB
Python
"""close_and_decide 마감 판정 검증 — 낙찰(AWARDED) 또는 개찰(OPEN_*, 낙찰자 미정 마감).
|
|
|
|
검증 대상:
|
|
- 동시 이중 마감 가드: 같은 견적을 동시에 close_and_decide 해도 실제 마감 전이는 1번만
|
|
- 낙찰 기준 게이트: 단독 최저가가 기준 통과면 낙찰, 미달이면 개찰(가격)
|
|
- 동가 / 협상거부 / 전원 미응찰 → 각각 개찰(OPEN_EQUAL / OPEN_REJECT / OPEN_NOSHOW)
|
|
|
|
용어: 개찰 = 낙찰자 미정으로 마감(결렬 아님). 자동 재협상/재생성 없음 — 다음 라운드는 담당자가 수동 재생성.
|
|
"""
|
|
import asyncio
|
|
import uuid
|
|
from datetime import datetime
|
|
|
|
import pytest_asyncio
|
|
from sqlalchemy import text
|
|
|
|
from common.enums import CloseOutcome, CloseReason, PriceGateAction, QuotationStatus, QuotationType, SessionStatus
|
|
from crud.quotation_crud import QuotationCRUD
|
|
from services.quotation_service import QuotationService
|
|
|
|
PAST = datetime(2020, 1, 1)
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def clean(db_engine):
|
|
async with db_engine.begin() as conn:
|
|
await conn.execute(text("TRUNCATE TABLE sessions, quotations RESTART IDENTITY CASCADE"))
|
|
return db_engine
|
|
|
|
|
|
async def test_concurrent_close_single_transition(clean):
|
|
"""검증: 전원 미응찰 견적을 5번 동시에 close_and_decide.
|
|
기대결과: 실제 마감 전이(OPENED)는 1번만·나머지는 no-op(CLOSED), 체인 [1](자동 재생성 없음), close_reason=OPEN_NOSHOW."""
|
|
engine = clean
|
|
number = "C-CONCURRENT"
|
|
qt = await _seed_quotation(engine, number=number, round_=1, status=QuotationStatus.IN_PROGRESS.value)
|
|
# 전원 미응찰(미시작 세션만) → 개찰(미응찰) 경로
|
|
await _add_session(engine, qt, status=SessionStatus.CREATED.value)
|
|
await _add_session(engine, qt, status=SessionStatus.CREATED.value)
|
|
|
|
service = QuotationService(QuotationCRUD())
|
|
outcomes = await asyncio.gather(*[service.close_and_decide(qt) for _ in range(5)])
|
|
|
|
opened = sum(1 for o in outcomes if o == CloseOutcome.OPENED)
|
|
rounds = await _rounds(engine, number)
|
|
assert opened == 1, f"실제 마감 전이는 1번만이어야 함, 실제 {opened} ({outcomes})"
|
|
assert [r.round for r in rounds] == [1], "자동 재생성 없음 — round 2 가 생기면 안 됨"
|
|
assert rounds[0].close_reason == CloseReason.OPEN_NOSHOW.value
|
|
|
|
|
|
async def test_single_lowest_meets_target_awarded(clean):
|
|
"""검증: 단독 최저가(bid=100)가 목표가(200) 이내 · 목표까지 낙찰(mid=AWARD).
|
|
기대결과: AWARDED — 그 협력사로 낙찰(preferred_sp_yn=True), close_reason=AWARDED."""
|
|
engine = clean
|
|
qt = await _seed_quotation(engine, number="C-AWARD", round_=1,
|
|
status=QuotationStatus.IN_PROGRESS.value,
|
|
mid_action=PriceGateAction.AWARD.value, over_action=PriceGateAction.OPEN.value)
|
|
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, target_price=200)
|
|
|
|
outcome = await QuotationService(QuotationCRUD()).close_and_decide(qt)
|
|
|
|
rounds = await _rounds(engine, "C-AWARD")
|
|
assert outcome == CloseOutcome.AWARDED, f"기준 통과 단독 최저가 → 낙찰이어야 함, 실제 {outcome}"
|
|
assert rounds[0].close_reason == CloseReason.AWARDED.value
|
|
|
|
|
|
async def test_over_target_opens(clean):
|
|
"""검증: 단독 최저가(bid=200)가 목표가(100) 초과 · 목표초과=개찰(over=OPEN).
|
|
기대결과: OPENED + close_reason=OPEN_PRICE — 낙찰 안 하고 개찰(자동 재생성 없이 체인 [1])."""
|
|
engine = clean
|
|
qt = await _seed_quotation(engine, number="C-OVER", round_=1,
|
|
status=QuotationStatus.IN_PROGRESS.value,
|
|
mid_action=PriceGateAction.AWARD.value, over_action=PriceGateAction.OPEN.value)
|
|
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=200, target_price=100)
|
|
|
|
outcome = await QuotationService(QuotationCRUD()).close_and_decide(qt)
|
|
|
|
rounds = await _rounds(engine, "C-OVER")
|
|
assert outcome == CloseOutcome.OPENED, f"목표초과 → 개찰이어야 함, 실제 {outcome}"
|
|
assert [r.round for r in rounds] == [1], "자동 재생성 없음(round 2 없음)"
|
|
assert rounds[0].close_reason == CloseReason.OPEN_PRICE.value
|
|
|
|
|
|
async def test_anchor_only_opens_within_target(clean):
|
|
"""검증: '앵커링가까지만 낙찰'(mid=OPEN) 에서 앵커(80)<최저가(120)≤목표(200).
|
|
기대결과: OPENED + OPEN_PRICE — 앵커 위 구간은 낙찰 안 하고 개찰."""
|
|
engine = clean
|
|
qt = await _seed_quotation(engine, number="C-ANCHOR", round_=1,
|
|
status=QuotationStatus.IN_PROGRESS.value,
|
|
mid_action=PriceGateAction.OPEN.value, over_action=PriceGateAction.OPEN.value)
|
|
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=120,
|
|
target_price=200, anchoring_price=80)
|
|
|
|
outcome = await QuotationService(QuotationCRUD()).close_and_decide(qt)
|
|
|
|
rounds = await _rounds(engine, "C-ANCHOR")
|
|
assert outcome == CloseOutcome.OPENED
|
|
assert rounds[0].close_reason == CloseReason.OPEN_PRICE.value
|
|
|
|
|
|
async def test_equal_lowest_opens(clean):
|
|
"""검증: 최저가 동점(둘 다 100, 목표 200 이내).
|
|
기대결과: OPENED + close_reason=OPEN_EQUAL(낙찰자 미정) — 자동 재입찰 없음(체인 [1])."""
|
|
engine = clean
|
|
qt = await _seed_quotation(engine, number="C-EQUAL", round_=1, status=QuotationStatus.IN_PROGRESS.value)
|
|
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, target_price=200)
|
|
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, target_price=200)
|
|
|
|
outcome = await QuotationService(QuotationCRUD()).close_and_decide(qt)
|
|
|
|
rounds = await _rounds(engine, "C-EQUAL")
|
|
assert outcome == CloseOutcome.OPENED, f"동가 → 개찰이어야 함, 실제 {outcome}"
|
|
assert [r.round for r in rounds] == [1]
|
|
assert rounds[0].close_reason == CloseReason.OPEN_EQUAL.value
|
|
|
|
|
|
async def test_rejected_opens(clean):
|
|
"""검증: 완료 투찰 없이 협상거부 세션만 존재.
|
|
기대결과: OPENED + close_reason=OPEN_REJECT."""
|
|
engine = clean
|
|
qt = await _seed_quotation(engine, number="C-REJECT", round_=1, status=QuotationStatus.IN_PROGRESS.value)
|
|
await _add_session(engine, qt, status=SessionStatus.REJECTED.value)
|
|
|
|
outcome = await QuotationService(QuotationCRUD()).close_and_decide(qt)
|
|
|
|
rounds = await _rounds(engine, "C-REJECT")
|
|
assert outcome == CloseOutcome.OPENED, f"협상거부 → 개찰이어야 함, 실제 {outcome}"
|
|
assert rounds[0].close_reason == CloseReason.OPEN_REJECT.value
|
|
|
|
|
|
# ===== 헬퍼 (세션 상태·마감 표식을 SQL 로 직접 세팅) =====
|
|
async def _seed_quotation(
|
|
engine, *, number, round_, status, start_time=PAST, end_time=PAST,
|
|
preferred_sp_yn=None, equal_bid_yn=None, close_reason=None, qt_setting_id=None,
|
|
mid_action=1, over_action=1,
|
|
):
|
|
"""견적 1건 시드. number/round_ 로 체인을, close_reason 으로 이전 라운드 마감 사유를 만든다.
|
|
낙찰 기준(mid/over)은 견적 행에 직접 박제 — close_and_decide 가 이 행에서 읽는다(세팅 아님)."""
|
|
qt_id = uuid.uuid4()
|
|
async with engine.begin() as conn:
|
|
await conn.execute(
|
|
text(
|
|
"INSERT INTO quotations "
|
|
"(qt_id, user_id, qt_setting_id, version_id, name, number, type, status, "
|
|
" round, iteration, start_time, end_time, deleted, preferred_sp_yn, equal_bid_yn, close_reason, "
|
|
" mid_action, over_action) VALUES "
|
|
"(:qt_id, :user_id, :qt_setting_id, :version_id, :name, :number, :type, :status, "
|
|
" :round, 0, :start_time, :end_time, false, :pref, :eq, :creason, "
|
|
" :mid, :over)"
|
|
),
|
|
{
|
|
"qt_id": qt_id, "user_id": uuid.uuid4(), "qt_setting_id": qt_setting_id or uuid.uuid4(),
|
|
"version_id": uuid.uuid4(), "name": "견적", "number": number,
|
|
"type": QuotationType.REQUOTE.value, "status": status, "round": round_,
|
|
"start_time": start_time, "end_time": end_time,
|
|
"pref": preferred_sp_yn, "eq": equal_bid_yn, "creason": close_reason,
|
|
"mid": mid_action, "over": over_action,
|
|
},
|
|
)
|
|
return qt_id
|
|
|
|
|
|
async def _add_session(engine, qt_id, *, status, bid_price=None, supplier_id=None,
|
|
target_price=0, anchoring_price=None):
|
|
"""세션 1건 시드(공급사 협상 1건). target_price/앵커링가로 가격게이트 입력을 만든다."""
|
|
async with engine.begin() as conn:
|
|
await conn.execute(
|
|
text(
|
|
"INSERT INTO sessions "
|
|
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, "
|
|
" target_price, anchoring_price, status, bid_price, end_time) VALUES "
|
|
"(:session_id, :quotation_id, :item_id, :supplier_id, :qt_number, :qt_round, :qt_type, "
|
|
" :target_price, :anchor, :status, :bid_price, :end_time)"
|
|
),
|
|
{
|
|
"session_id": uuid.uuid4(), "quotation_id": qt_id, "item_id": uuid.uuid4(),
|
|
"supplier_id": supplier_id or uuid.uuid4(), "qt_number": "Q", "qt_round": 1,
|
|
"qt_type": QuotationType.REQUOTE.value, "status": status,
|
|
"target_price": target_price, "anchor": anchoring_price,
|
|
"bid_price": bid_price, "end_time": PAST,
|
|
},
|
|
)
|
|
|
|
|
|
async def _rounds(engine, number):
|
|
"""체인(number)의 (round, status, close_reason) 목록 — round 오름차순."""
|
|
async with engine.begin() as conn:
|
|
return (await conn.execute(
|
|
text("SELECT round, status, close_reason FROM quotations WHERE number = :n ORDER BY round"),
|
|
{"n": number},
|
|
)).all()
|