협상 불가 사유를 내면 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 통과. 프론트 빌드/린트 통과.
265 lines
13 KiB
Python
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())
|