- 마감사유 quotations.close_reason(SMALLINT, 8종) 신설 — 낙찰/재협상3종/유찰4종 구분, 재생성 한도 카운팅. quotation_settings 에 mid_action·over_action·regen_limit(3구간 가격정책) 추가. models.py·enums.py·01-schema.sql·crud·service·테스트 정합. - 프론트 노출: CloseReason/PriceGateAction 라벨·배지, 목록 마감결과 컬럼, 견적세팅 모달 3구간. orval 생성모델 갱신. - 견적상세 결과밴드: 목표가→낙찰가→절감 stat 트리오를 단가조정 흐름 스펙트럼(목표가·최저·최고 투찰가)으로 교체. lowestBid/highestBid 는 bid_price 파생(DB 컬럼 아님), 최초가 컬럼 없어 시작앵커=최고 투찰가로 대체. 라벨 방향 값순 번갈아 배치로 겹침 방지. - 라벨 정정: "단독 낙찰"→"낙찰"(낙찰은 단수), "목표 대비"→"목표가 대비". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
231 lines
12 KiB
Python
231 lines
12 KiB
Python
"""close_and_decide 동시성·정합성 수정 검증 (코드리뷰 후속).
|
|
|
|
검증 대상:
|
|
- #2 동시 이중 마감 가드: 같은 견적을 동시에 close_and_decide 해도 다음 라운드는 1개만 생성
|
|
- #3 차수 매김: 다음 라운드 round = 체인 최신 round + 1
|
|
- #4 재생성 사유 집계: 단독낙찰(preferred_sp_yn=True) 이전 라운드를 '미참여'로 오집계하지 않음
|
|
- #6 재생성 라운드 최소 협상기간 하한(즉시 재마감 캐스케이드 방지)
|
|
|
|
용어: 체인 = 같은 견적번호(number)로 이어지는 라운드들 / 미참여 = 공급사가 협상에 안 들어온 채 마감됨 /
|
|
재생성 = 결판 안 난 견적의 '다음 라운드'를 자동 생성 / 재생성 한도 = 사유(미참여·동가)별로 체인당 1번까지만.
|
|
"""
|
|
import asyncio
|
|
import uuid
|
|
from datetime import datetime, timedelta
|
|
|
|
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_creates_only_one_next_round(clean):
|
|
"""검증: 같은 견적(전원 미참여)을 5번 동시에 close_and_decide.
|
|
기대결과: 재생성은 1번만(REGENERATED=1), 체인은 [1,2] — 이중 재생성/충돌 없음."""
|
|
engine = clean
|
|
number = "C-CONCURRENT"
|
|
qt = await _seed_quotation(engine, number=number, round_=1, status=QuotationStatus.IN_PROGRESS.value)
|
|
# 전원 미참여(미시작 세션만) → close_and_decide 가 '다음 라운드 재생성' 경로를 탄다
|
|
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)])
|
|
|
|
regenerated = sum(1 for o in outcomes if o == CloseOutcome.REGENERATED)
|
|
rounds = await _rounds(engine, number)
|
|
round_numbers = [r.round for r in rounds]
|
|
|
|
assert regenerated == 1, f"재생성은 1번만 일어나야 함, 실제 {regenerated} ({outcomes})"
|
|
assert round_numbers == [1, 2], f"체인은 [1,2] 여야 함(중복/충돌 없음), 실제 {round_numbers}"
|
|
|
|
|
|
async def test_next_round_numbering_and_min_duration(clean):
|
|
"""검증: 협상기간이 0인 견적을 미참여로 재생성.
|
|
기대결과: 체인 [1,2](round=최신+1), 새 라운드 협상기간 ≥ MIN_REGEN_DURATION(즉시 재마감 방지)."""
|
|
engine = clean
|
|
number = "C-DURATION"
|
|
# start==end (협상기간 0) → 하한이 적용되지 않으면 새 라운드도 0 길이가 된다
|
|
qt = await _seed_quotation(
|
|
engine, number=number, round_=1, status=QuotationStatus.IN_PROGRESS.value,
|
|
start_time=PAST, end_time=PAST,
|
|
)
|
|
await _add_session(engine, qt, status=SessionStatus.CREATED.value)
|
|
|
|
service = QuotationService(QuotationCRUD())
|
|
outcome = await service.close_and_decide(qt)
|
|
|
|
assert outcome == CloseOutcome.REGENERATED
|
|
rounds = await _rounds(engine, number)
|
|
assert [r.round for r in rounds] == [1, 2]
|
|
nxt = rounds[1]
|
|
duration = nxt.end_time - nxt.start_time
|
|
assert duration >= QuotationService.MIN_REGEN_DURATION, (
|
|
f"재생성 라운드 협상기간({duration})이 최소 하한({QuotationService.MIN_REGEN_DURATION}) 이상이어야 함"
|
|
)
|
|
|
|
|
|
async def test_awarded_prior_round_not_counted_as_no_show(clean):
|
|
"""검증: round1=단독낙찰 + round2=전원 미참여 인 체인에서 round2 를 마감.
|
|
기대결과: REGENERATED, 체인 [1,2,3] — 단독낙찰 라운드를 '미참여'로 오집계해 재생성을 막지 않는다."""
|
|
engine = clean
|
|
number = "C-AWARDED-PRIOR"
|
|
# round 1: 단독낙찰로 마감(close_reason=AWARDED). 수동 재생성 등으로 체인이 이어진 상황을 가정.
|
|
await _seed_quotation(
|
|
engine, number=number, round_=1, status=QuotationStatus.CLOSED.value,
|
|
preferred_sp_yn=True, equal_bid_yn=False, close_reason=CloseReason.AWARDED.value,
|
|
)
|
|
# round 2: 전원 미참여 → 미참여 재생성이 일어나야 한다(round 1 은 미참여로 세면 안 됨)
|
|
qt2 = await _seed_quotation(engine, number=number, round_=2, status=QuotationStatus.IN_PROGRESS.value)
|
|
await _add_session(engine, qt2, status=SessionStatus.CREATED.value)
|
|
|
|
service = QuotationService(QuotationCRUD())
|
|
outcome = await service.close_and_decide(qt2)
|
|
|
|
rounds = await _rounds(engine, number)
|
|
round_numbers = [r.round for r in rounds]
|
|
assert outcome == CloseOutcome.REGENERATED, (
|
|
f"단독낙찰 이전 라운드는 미참여 예산을 소진하지 않아 round2 가 재생성돼야 함, 실제 {outcome}"
|
|
)
|
|
assert round_numbers == [1, 2, 3], f"round 3 이 생성돼야 함, 실제 {round_numbers}"
|
|
|
|
|
|
async def test_no_show_prior_round_consumes_budget(clean):
|
|
"""검증: round1=미참여 재생성 + round2=전원 미참여 인 체인에서 round2 를 마감.
|
|
기대결과: CLOSED, 체인 [1,2] — 미참여 재생성 한도(1) 소진돼 재생성 없이 그냥 마감(round3 없음)."""
|
|
engine = clean
|
|
number = "C-NOSHOW-PRIOR"
|
|
# round 1: 미참여 재생성으로 마감(close_reason=REGEN_NOSHOW) → 미참여 예산 1 소진
|
|
await _seed_quotation(
|
|
engine, number=number, round_=1, status=QuotationStatus.CLOSED.value,
|
|
preferred_sp_yn=False, equal_bid_yn=False, close_reason=CloseReason.REGEN_NOSHOW.value,
|
|
)
|
|
# round 2: 또 전원 미참여 → 한도 도달이라 재생성 없이 그냥 마감
|
|
qt2 = await _seed_quotation(engine, number=number, round_=2, status=QuotationStatus.IN_PROGRESS.value)
|
|
await _add_session(engine, qt2, status=SessionStatus.CREATED.value)
|
|
|
|
service = QuotationService(QuotationCRUD())
|
|
outcome = await service.close_and_decide(qt2)
|
|
|
|
rounds = await _rounds(engine, number)
|
|
assert outcome == CloseOutcome.CLOSED, f"미참여 예산 소진 → 그냥 마감이어야 함, 실제 {outcome}"
|
|
assert [r.round for r in rounds] == [1, 2], "재생성되면 안 됨(round 3 없음)"
|
|
|
|
|
|
async def test_over_target_fail_closes(clean):
|
|
"""검증: over_action=유찰(FAIL) 회사에서 단독 최저가가 목표 초과(session target=0 < bid=100)인 견적을 마감.
|
|
기대결과: CLOSED + close_reason=FAIL_PRICE — 그 가격에 낙찰 안 하고 유찰(재생성 없음)."""
|
|
engine = clean
|
|
setting = await _seed_settings(engine, over_action=PriceGateAction.FAIL.value)
|
|
qt = await _seed_quotation(engine, number="C-OVER-FAIL", round_=1,
|
|
status=QuotationStatus.IN_PROGRESS.value, qt_setting_id=setting)
|
|
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100)
|
|
|
|
outcome = await QuotationService(QuotationCRUD()).close_and_decide(qt)
|
|
|
|
rounds = await _rounds(engine, "C-OVER-FAIL")
|
|
assert outcome == CloseOutcome.CLOSED, f"목표초과+유찰정책 → 유찰이어야 함, 실제 {outcome}"
|
|
assert [r.round for r in rounds] == [1], "재생성되면 안 됨(round 2 없음)"
|
|
assert rounds[0].close_reason == CloseReason.FAIL_PRICE.value, f"close_reason=FAIL_PRICE 여야 함, 실제 {rounds[0].close_reason}"
|
|
|
|
|
|
async def test_over_target_renego_regenerates(clean):
|
|
"""검증: over_action=재협상(RENEGO) 회사에서 단독 최저가가 목표 초과인 견적을 첫 라운드에 마감.
|
|
기대결과: REGENERATED + 체인 [1,2] — 그 가격에 낙찰 안 하고 다음 라운드로 더 깎기(1차 close_reason=REGEN_PRICE)."""
|
|
engine = clean
|
|
setting = await _seed_settings(engine, over_action=PriceGateAction.RENEGO.value)
|
|
qt = await _seed_quotation(engine, number="C-OVER-RENEGO", round_=1,
|
|
status=QuotationStatus.IN_PROGRESS.value, qt_setting_id=setting)
|
|
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100)
|
|
|
|
outcome = await QuotationService(QuotationCRUD()).close_and_decide(qt)
|
|
|
|
rounds = await _rounds(engine, "C-OVER-RENEGO")
|
|
assert outcome == CloseOutcome.REGENERATED, f"목표초과+재협상정책 0회차 → 재생성이어야 함, 실제 {outcome}"
|
|
assert [r.round for r in rounds] == [1, 2], f"round 2 가 생성돼야 함, 실제 {[r.round for r in rounds]}"
|
|
assert rounds[0].close_reason == CloseReason.REGEN_PRICE.value, f"1차 close_reason=REGEN_PRICE 여야 함, 실제 {rounds[0].close_reason}"
|
|
|
|
|
|
# ===== 헬퍼 (위 테스트들이 쓰는 도우미. 세션 상태·마감 표식을 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,
|
|
):
|
|
"""견적 1건 시드. number/round_ 로 체인을, close_reason 으로 '이전 라운드가 어떤 사유로 마감/재생성됐는지'를 만든다
|
|
(재생성 한도 카운팅은 close_reason 의 REGEN_* 만 센다). qt_setting_id 로 마감 가격정책(설정)을 연결. preferred_sp_yn/equal_bid_yn 은 프론트 표시용."""
|
|
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) VALUES "
|
|
"(:qt_id, :user_id, :qt_setting_id, :version_id, :name, :number, :type, :status, "
|
|
" :round, 0, :start_time, :end_time, false, :pref, :eq, :creason)"
|
|
),
|
|
{
|
|
"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,
|
|
},
|
|
)
|
|
return qt_id
|
|
|
|
|
|
async def _add_session(engine, qt_id, *, status, bid_price=None, supplier_id=None):
|
|
"""세션 1건 시드(공급사 협상 1건)."""
|
|
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, status, bid_price, end_time) VALUES "
|
|
"(:session_id, :quotation_id, :item_id, :supplier_id, :qt_number, :qt_round, :qt_type, "
|
|
" 0, :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,
|
|
"bid_price": bid_price, "end_time": PAST,
|
|
},
|
|
)
|
|
|
|
|
|
async def _rounds(engine, number):
|
|
"""체인(number)의 (round, status, start_time, end_time, close_reason) 목록 — round 오름차순."""
|
|
async with engine.begin() as conn:
|
|
return (await conn.execute(
|
|
text("SELECT round, status, start_time, end_time, close_reason FROM quotations "
|
|
"WHERE number = :n ORDER BY round"),
|
|
{"n": number},
|
|
)).all()
|
|
|
|
|
|
async def _seed_settings(engine, *, mid_action=1, over_action=1, regen_limit=1):
|
|
"""quotation_settings 1건 시드(마감 가격정책 지정). 반환: qt_setting_id."""
|
|
sid = uuid.uuid4()
|
|
async with engine.begin() as conn:
|
|
await conn.execute(
|
|
text(
|
|
"INSERT INTO quotation_settings "
|
|
"(qt_setting_id, user_id, target_margin_rate, anchoring_value, card_count, mid_action, over_action, regen_limit) "
|
|
"VALUES (:sid, :uid, 0.1, 0.01, 3, :mid, :over, :lim)"
|
|
),
|
|
{"sid": sid, "uid": uuid.uuid4(), "mid": mid_action, "over": over_action, "lim": regen_limit},
|
|
)
|
|
return sid
|