o2o-negosium-original/negodata/backend/tests/test_close_and_decide_fixes.py
hbyang 7e0f88ca03 [fix] 견적 자동마감 동시성·정합성 + 프론트 안정화 (코드리뷰 후속)
백엔드 close_and_decide 경로:
- 동시 이중 마감 가드: 마감 판정 전 원자적 CLOSED 선점(claim)으로 두 크론 잡·수동마감 경합 직렬화
- 재생성 실패 표면화: regenerate_next_round 결과 검사 → 실패 시 REGEN_FAILED 반환(체인 끊김 은폐 방지)
- 차수 충돌 방지: 다음 라운드 = 체인 최신 round+1(chain_max_round 기준)
- 재생성 사유 집계 정밀화: 미참여/동가를 양성 표식으로 구분(단독낙찰·거부 오집계 제거)
- 재생성 라운드 최소 협상기간 하한(즉시 재마감 캐스케이드 방지)
- 잡 루프 per-item 예외 격리(한 건 실패가 배치 전체를 멈추지 않음)

프론트:
- useChatController: 무권한 가드를 sessionId 별로 추적해 세션 변경 시 자연 해제
- useScrollLock: 마지막 해제를 rAF 로 지연해 재마운트 사이 일시적 잠금 해제 방지
- quotation 상세 쿼리 placeholderData 로 라운드 전환 중 시트 유지

테스트:
- 신규 test_close_and_decide_fixes.py(동시성·차수·집계·기간 하한 검증)
- conftest 결함 수정(존재하지 않는 tbl_account TRUNCATE 제거, companies.status 명시)
- stale 테스트 갱신(test_quotation_create 를 타입드 Req/신규 응답 형식에 맞게 재작성)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 17:22:21 +09:00

179 lines
8.5 KiB
Python

"""close_and_decide 동시성·정합성 수정 검증 (코드리뷰 후속).
검증 대상:
- #2 동시 이중 마감 가드: 같은 견적을 동시에 close_and_decide 해도 다음 라운드는 1개만 생성
- #3 차수 매김: 다음 라운드 round = 체인 최신 round + 1
- #4 재생성 사유 집계: 단독낙찰(preferred_sp_yn=True) 이전 라운드를 '미참여'로 오집계하지 않음
- #6 재생성 라운드 최소 협상기간 하한(즉시 재마감 캐스케이드 방지)
실행 전제: tests/test_scheduler.py 와 동일(PostgreSQL, APP_ENV=test).
"""
import asyncio
import uuid
from datetime import datetime, timedelta
import pytest_asyncio
from sqlalchemy import text
from common.enums import CloseOutcome, 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 _seed_quotation(
engine, *, number, round_, status, start_time=PAST, end_time=PAST,
preferred_sp_yn=None, equal_bid_yn=None,
):
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) VALUES "
"(:qt_id, :user_id, :qt_setting_id, :version_id, :name, :number, :type, :status, "
" :round, 0, :start_time, :end_time, false, :pref, :eq)"
),
{
"qt_id": qt_id, "user_id": uuid.uuid4(), "qt_setting_id": 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,
},
)
return qt_id
async def _add_session(engine, qt_id, *, status, bid_price=None, supplier_id=None):
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, end_time, start_time) 목록 — round 오름차순."""
async with engine.begin() as conn:
return (await conn.execute(
text("SELECT round, status, start_time, end_time FROM quotations "
"WHERE number = :n ORDER BY round"),
{"n": number},
)).all()
# ----- #2 동시 이중 마감 가드 -----
async def test_concurrent_close_creates_only_one_next_round(clean):
"""같은 견적을 5번 동시에 close_and_decide 해도 다음 라운드는 정확히 1개만 생성된다."""
engine = clean
number = "C-CONCURRENT"
qt = await _seed_quotation(engine, number=number, round_=1, status=QuotationStatus.ACTIVE.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}"
# ----- #3 차수 + #6 최소 협상기간 하한 -----
async def test_next_round_numbering_and_min_duration(clean):
"""다음 라운드 round = 최신+1, 협상기간이 0이어도 최소 하한(MIN_REGEN_DURATION)이 적용된다."""
engine = clean
number = "C-DURATION"
# start==end (협상기간 0) → 하한이 적용되지 않으면 새 라운드도 0 길이가 된다
qt = await _seed_quotation(
engine, number=number, round_=1, status=QuotationStatus.ACTIVE.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}) 이상이어야 함"
)
# ----- #4 재생성 사유 집계: 단독낙찰 이전 라운드를 미참여로 오집계하지 않음 -----
async def test_awarded_prior_round_not_counted_as_no_show(clean):
"""체인에 '단독낙찰'(preferred_sp_yn=True) 이전 라운드가 있어도, 이후 라운드의 미참여 재생성 예산을 소진하지 않는다.
(구버전: equal_bid_yn=False 인 단독낙찰 라운드를 미참여로 세어 round2 재생성이 막혔다.)"""
engine = clean
number = "C-AWARDED-PRIOR"
# round 1: 단독낙찰로 마감(preferred_sp_yn=True). 수동 재생성 등으로 체인이 이어진 상황을 가정.
await _seed_quotation(
engine, number=number, round_=1, status=QuotationStatus.CLOSED.value,
preferred_sp_yn=True, equal_bid_yn=False,
)
# round 2: 전원 미참여 → 미참여 재생성이 일어나야 한다(round 1 은 미참여로 세면 안 됨)
qt2 = await _seed_quotation(engine, number=number, round_=2, status=QuotationStatus.ACTIVE.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}"
# ----- #4 대비: 실제 미참여 이전 라운드는 예산을 소진(한도 1) -----
async def test_no_show_prior_round_consumes_budget(clean):
"""이전 라운드가 '미참여 재생성'(preferred_sp_yn=False, equal_bid_yn=False)이면 예산(1)을 소진 →
다음 라운드의 미참여는 재생성 없이 그냥 마감된다."""
engine = clean
number = "C-NOSHOW-PRIOR"
# round 1: 미참여로 마감(양성 표식) → no_part 예산 1 소진
await _seed_quotation(
engine, number=number, round_=1, status=QuotationStatus.CLOSED.value,
preferred_sp_yn=False, equal_bid_yn=False,
)
# round 2: 또 전원 미참여 → 한도 도달이라 재생성 없이 그냥 마감
qt2 = await _seed_quotation(engine, number=number, round_=2, status=QuotationStatus.ACTIVE.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 없음)"