o2o-negosium-original/negodata/backend/tests/test_quotation_award.py
Mina Choi 6eb4dc26f8 [feat] negosium·negodata: 협상 거부 흐름 정리 + 오프라인 계약가 낙찰 + 견적 상세 리모델링
협상 불가 사유를 내면 500 이 나고, 거부 폼은 목록·채팅이 따로 놀았으며,
제출한 내용을 다시 볼 방법이 없었다. 결렬 건은 협력사가 낸 거부가를 계약가로
간주해 낙찰시켜 절감 통계가 음수로 뒤집힐 수 있었고, 견적 상세는 판정 가격이
세 곳에 흩어져 대화 탭에선 아예 보이지 않았다.

agent
- 결렬 종료 로깅 크래시 수정 — _log 를 action_id 기반으로 되돌리고 선택 근거
  (Q·UCB·방문수)는 decision/policy 가 있을 때만 채운다. 종료 행은 카드 선택이
  없고 policy.update 뒤라 값을 넣으면 학습 화면 집계가 오염된다

negosium
- 거부 폼을 목록·채팅 공용 컴포넌트 하나로 통일(사유 3종 + 공급 희망가·의견 선택)
- 거부 사유 열람 — 목록에 '거부 사유 보기'(부가정보 보기와 같은 규격), 채팅
  재진입 시 대화 끝에 거부 내역 카드. 목록·채팅 init 응답에 reject_reason·reject_price 추가
- 자유 입력 거부("협상 포기합니다")가 사유 NULL 로 저장되던 문제 수정 — 폼 마커가
  없으면 원문을 사유로 쓰고, 문장 속 숫자를 희망가로 오인하지 않는다
- koreanNumber 를 전역 lib 으로 이동(공용 폼이 쓴다)

negodata
- 직접 낙찰에 계약가 입력 — 결렬·미응찰 건을 오프라인으로 다시 협상한 결과를
  담당자가 확정해 넣는다. 후보는 초청 협력사 전부(가격 미제출도 포함),
  계약가는 sessions.custom.offline_award 에 근거·작성자·시각과 함께 남긴다
- 통계 계약가 = 담당자 확정가 우선, 없으면 투찰가. 거부가를 계약가로 치던 파생 제거.
  KPI 에 오프라인 반영 건수 추가
- 견적 상세 리모델링 — 가격 레일(앵커링가/투찰현황 · 목표가 · 타결 상한가 · 결과가)을
  시트에 고정해 접힘·탭 전환에도 남기고, 스펙트럼에 타결 판정선과 구간색 추가.
  라벨은 폭을 실측해 두 레인으로 배치(겹침 불가). 상품·마감시각 등 전 행 동일 컬럼 제거,
  협상현황에 부가정보 노출, 1:1 은 협력사·세션상태를 결과 밴드로 올림

테스트: negosium 58 · negodata 110 통과. 프론트 빌드/린트 통과.
2026-08-12 10:25:26 +09:00

265 lines
13 KiB
Python

"""개찰 견적 직접 낙찰(award_quotation) 테스트 — 담당자가 개찰(낙찰자 미정 마감) 견적의 낙찰자를 직접 지정.
직접 낙찰은 자동 낙찰(close_and_decide)과 결과 컬럼은 같되(close_reason=AWARDED, preferred_sp_*),
알림에 manual 플래그로 '직접' 낙찰임을 남긴다. 다음을 본다:
· 개찰 + 투찰(DONE) 협력사 지정 → 낙찰 확정 + 알림 SUCCESS(manual=True)
· 개찰 아님(이미 낙찰) → 거부(INVALID_REQUEST_DATA), 알림 없음
· 후보 아닌 협력사 지정 → 거부, close_reason 유지
· 낙찰 후 재지정(재클릭) → 거부(동시성 가드), 알림 1건 유지
세션의 협상 결과(협상완료/입찰가)와 개찰 상태(close_reason)는 협상/마감에서만 생기는 값이라 SQL 로 직접 넣는다.
"""
import uuid
from datetime import datetime
import pytest_asyncio
from sqlalchemy import text
from common.enums import CloseReason, ErrorType, QuotationStatus, QuotationType, SessionStatus, UserRole
from crud.quotation_crud import QuotationCRUD
from services.quotation import QuotationService
PAST = datetime(2020, 1, 1)
@pytest_asyncio.fixture
async def clean(db_engine):
"""conftest 는 notifications 를 비우지 않는다 → 알림 단언이 다른 테스트에 안 흔들리게 여기서 함께 비운다."""
async with db_engine.begin() as conn:
await conn.execute(text("TRUNCATE TABLE sessions, quotations, notifications RESTART IDENTITY CASCADE"))
return db_engine
async def test_award_opened_sets_winner_and_notifies(clean):
"""검증: 개찰(동가) 견적 + 투찰 협력사 2건(A=100, B=120) 중 A 를 직접 낙찰.
기대결과: close_reason→낙찰, preferred_sp=A, 동가플래그 해제 + 알림 SUCCESS(manual=True, winner_price=100)."""
engine = clean
user_id, supplier_a = uuid.uuid4(), uuid.uuid4()
qt = await _seed_opened(engine, user_id=user_id, number="A-WIN", close_reason=CloseReason.OPEN_EQUAL.value)
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a)
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=120)
res = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, supplier_a, 100)
assert res.result.success is True
row = await _quotation(engine, qt)
assert row.close_reason == CloseReason.AWARDED.value
assert row.preferred_sp_yn is True
assert str(row.preferred_sp_id) == str(supplier_a)
assert row.equal_bid_yn is False
notis = await _notifications(engine, user_id)
assert len(notis) == 1
type_, data, ref = notis[0]
assert type_ == 1 # NotificationType.SUCCESS
assert data["manual"] is True
assert data["winner_price"] == 100
assert str(ref) == str(qt)
async def test_award_rejects_when_not_opened(clean):
"""검증: 이미 낙찰된 견적(close_reason=AWARDED)에 직접 낙찰을 다시 시도.
기대결과: 거부(INVALID_REQUEST_DATA) + 새 알림 없음."""
engine = clean
user_id, supplier_a = uuid.uuid4(), uuid.uuid4()
qt = await _seed_opened(engine, user_id=user_id, number="A-DONE", close_reason=CloseReason.AWARDED.value)
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a)
res = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, supplier_a, 100)
assert res.result.success is False
assert res.result.code == ErrorType.INVALID_REQUEST_DATA.value
assert len(await _notifications(engine, user_id)) == 0
async def test_award_rejects_unknown_supplier(clean):
"""검증: 개찰 견적에, 이 견적에 초청되지 않은(세션이 없는) 협력사 id 를 지정.
기대결과: 거부 + close_reason 은 개찰(OPEN_PRICE) 그대로 유지, 알림 없음."""
engine = clean
user_id, bidder, stranger = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
qt = await _seed_opened(engine, user_id=user_id, number="A-STRANGER", close_reason=CloseReason.OPEN_PRICE.value)
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=bidder)
res = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, stranger, 100)
assert res.result.success is False
row = await _quotation(engine, qt)
assert row.close_reason == CloseReason.OPEN_PRICE.value
assert row.preferred_sp_id is None
assert len(await _notifications(engine, user_id)) == 0
async def test_award_is_idempotent(clean):
"""검증: 직접 낙찰 성공 후 같은 견적에 재지정(재클릭/경합).
기대결과: 2번째는 거부(이미 낙찰) + 알림은 1건만 유지(동시성 가드가 한 번만 통과)."""
engine = clean
user_id, supplier_a = uuid.uuid4(), uuid.uuid4()
qt = await _seed_opened(engine, user_id=user_id, number="A-IDEMP", close_reason=CloseReason.OPEN_REJECT.value)
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a)
first = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, supplier_a, 100)
second = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, supplier_a, 100)
assert first.result.success is True
assert second.result.success is False
assert len(await _notifications(engine, user_id)) == 1
async def test_award_rejects_non_owner(clean):
"""검증: 남의 개찰 견적을 일반 유저(비소유·USER)가 직접 낙찰 시도.
기대결과: 거부(ACCOUNT_FORBIDDEN) + close_reason 개찰 유지 + 알림 없음."""
engine = clean
owner, other, supplier_a = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
qt = await _seed_opened(engine, user_id=owner, number="A-NONOWNER", close_reason=CloseReason.OPEN_PRICE.value)
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a)
res = await _service().award_quotation(str(qt), None, other, UserRole.USER.value, supplier_a, 100)
assert res.result.success is False
assert res.result.code == ErrorType.ACCOUNT_FORBIDDEN.value
row = await _quotation(engine, qt)
assert row.close_reason == CloseReason.OPEN_PRICE.value
assert row.preferred_sp_id is None
assert len(await _notifications(engine, owner)) == 0
async def test_award_allows_owner_role(clean):
"""검증: 남의 개찰 견적을 최고관리자(OWNER)가 직접 낙찰.
기대결과: 낙찰 성공 + 알림은 견적 작성자(owner) 인박스에 남는다(호출자가 아니라)."""
engine = clean
creator, admin, supplier_a = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
qt = await _seed_opened(engine, user_id=creator, number="A-OWNER", close_reason=CloseReason.OPEN_EQUAL.value)
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a)
res = await _service().award_quotation(str(qt), None, admin, UserRole.OWNER.value, supplier_a, 100)
assert res.result.success is True
row = await _quotation(engine, qt)
assert row.close_reason == CloseReason.AWARDED.value
assert str(row.preferred_sp_id) == str(supplier_a)
assert len(await _notifications(engine, creator)) == 1
assert len(await _notifications(engine, admin)) == 0
async def test_award_records_contract_price_on_session(clean):
"""검증: 협력사 제출가(100)와 다른 계약가(88)로 직접 낙찰 — 오프라인 재협상 결과 반영.
기대결과: 낙찰 세션 custom.offline_award 에 계약가·메모가 남고, 알림 winner_price 도 계약가."""
engine = clean
user_id, supplier_a = uuid.uuid4(), uuid.uuid4()
qt = await _seed_opened(engine, user_id=user_id, number="A-OFFLINE", close_reason=CloseReason.OPEN_PRICE.value)
await _add_session(engine, qt, status=SessionStatus.REJECTED.value, bid_price=None, supplier_id=supplier_a)
res = await _service().award_quotation(
str(qt), None, user_id, UserRole.USER.value, supplier_a, 88, "오프라인 협상, 8/12 통화 합의",
)
assert res.result.success is True
async with engine.begin() as conn:
row = (await conn.execute(
text("SELECT custom FROM sessions WHERE quotation_id = :qt AND supplier_id = :sp"),
{"qt": qt, "sp": supplier_a},
)).first()
award = row.custom["offline_award"]
assert award["price"] == 88
assert award["note"] == "오프라인 협상, 8/12 통화 합의"
assert award["by"] == str(user_id)
notis = await _notifications(engine, user_id)
assert notis[0][1]["winner_price"] == 88
async def test_award_allows_supplier_without_price(clean):
"""검증: 시스템에 가격을 한 번도 안 낸(미참여) 협력사를 계약가와 함께 직접 낙찰.
기대결과: 낙찰 성공 — 전원 미응찰 견적도 오프라인 협상 결과를 반영할 수 있다."""
engine = clean
user_id, supplier_a = uuid.uuid4(), uuid.uuid4()
qt = await _seed_opened(engine, user_id=user_id, number="A-NOSHOW", close_reason=CloseReason.OPEN_NOSHOW.value)
await _add_session(engine, qt, status=SessionStatus.NOT_PARTICIPATED.value, bid_price=None, supplier_id=supplier_a)
res = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, supplier_a, 77_000)
assert res.result.success is True
row = await _quotation(engine, qt)
assert row.close_reason == CloseReason.AWARDED.value
assert str(row.preferred_sp_id) == str(supplier_a)
async def test_award_rejects_without_contract_price(clean):
"""검증: 계약가 없이(0) 직접 낙찰 시도.
기대결과: 거부(INVALID_REQUEST_DATA) — 계약가는 절감 통계의 기준이라 필수다."""
engine = clean
user_id, supplier_a = uuid.uuid4(), uuid.uuid4()
qt = await _seed_opened(engine, user_id=user_id, number="A-NOPRICE", close_reason=CloseReason.OPEN_PRICE.value)
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a)
res = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, supplier_a, 0)
assert res.result.success is False
assert res.result.code == ErrorType.INVALID_REQUEST_DATA.value
row = await _quotation(engine, qt)
assert row.close_reason == CloseReason.OPEN_PRICE.value
# ===== 헬퍼 =====
async def _seed_opened(engine, *, user_id, number, close_reason, round_=1):
"""개찰/낙찰 상태(status=CLOSED + close_reason)로 견적 1건 시드. 낙찰자 컬럼은 비운 채 시작."""
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, close_reason, "
" round, iteration, start_time, end_time, deleted) VALUES "
"(:qt_id, :user_id, :qt_setting_id, :version_id, '견적A', :number, :type, :status, :close_reason, "
" :round, 0, :start_time, :end_time, false)"
),
{
"qt_id": qt_id, "user_id": user_id, "qt_setting_id": uuid.uuid4(),
"version_id": uuid.uuid4(), "number": number, "type": QuotationType.REQUOTE.value,
"status": QuotationStatus.CLOSED.value, "close_reason": close_reason,
"round": round_, "start_time": PAST, "end_time": PAST,
},
)
return qt_id
async def _add_session(engine, qt_id, *, status, bid_price=None, supplier_id=None):
"""세션 1건 시드(공급사 협상 1건). status/bid_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, status, bid_price, end_time) VALUES "
"(:session_id, :quotation_id, :item_id, :supplier_id, 'Q', 1, :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_type": QuotationType.REQUOTE.value,
"status": status, "bid_price": bid_price, "end_time": PAST,
},
)
async def _quotation(engine, qt_id):
"""견적 1행(마감 결과 컬럼 확인용)."""
async with engine.begin() as conn:
return (await conn.execute(
text("SELECT close_reason, preferred_sp_yn, preferred_sp_id, equal_bid_yn "
"FROM quotations WHERE qt_id = :qt_id"),
{"qt_id": qt_id},
)).one()
async def _notifications(engine, user_id):
"""user_id(작성자) 인박스 알림 (type, data, ref_qt_id) — 생성순."""
async with engine.begin() as conn:
return (await conn.execute(
text("SELECT type, data, ref_qt_id FROM notifications WHERE user_id = :uid ORDER BY created_at"),
{"uid": user_id},
)).all()
def _service():
return QuotationService(QuotationCRUD())