협상 불가 사유를 내면 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 통과. 프론트 빌드/린트 통과.
548 lines
28 KiB
Python
548 lines
28 KiB
Python
"""협상 도메인 e2e 테스트 (세션 목록 + 참여).
|
|
|
|
dev negosium_db 를 그대로 쓰므로 전용 테스트 행만 시드/정리한다.
|
|
목록의 qt_end_time 은 quotation.end_time 기준이라 세션마다 견적을 함께 시드한다.
|
|
"""
|
|
|
|
import uuid
|
|
|
|
import bcrypt
|
|
import pytest_asyncio
|
|
from sqlalchemy import text
|
|
|
|
TEST_LOGIN_ID = "pytest_nego_user"
|
|
TEST_PW = "pytest1234"
|
|
TEST_SUPPLIER_NAME = "파이테스트협상공급사"
|
|
MARK = "PYTESTNEGO-" # 시드 식별용 prefix (item code / qt number)
|
|
|
|
|
|
async def _seed_case(conn, code, sess_st, qt_type, hrs, quote_st, sup):
|
|
"""상품·견적·세션 1세트 시드. 코드/견적번호에 MARK prefix 를 달아 cleanup 이 함께 지운다.
|
|
hrs 는 마감(quotation.end_time)까지의 시간 — 음수면 이미 마감시간이 지난 건. 반환: (session_id, qt_id)."""
|
|
item_id, qt_id, session_id = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
|
|
await conn.execute(
|
|
text(
|
|
"INSERT INTO partner.items (item_id, company_id, user_id, name, code, model_name, manufacturer) "
|
|
"VALUES (:iid, gen_random_uuid(), gen_random_uuid(), :name, :code, :model, '테스트제조사')"
|
|
),
|
|
{"iid": item_id, "name": f"상품 {code}", "code": f"{MARK}{code}", "model": f"MODEL-{code}"},
|
|
)
|
|
await conn.execute(
|
|
text(
|
|
"INSERT INTO quotation.quotations (qt_id, user_id, qt_setting_id, version_id, name, number, type, status, start_time, end_time) "
|
|
"VALUES (:qid, gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), :name, :num, :tp, :st, now(), now() + make_interval(hours => :hrs))"
|
|
),
|
|
{"qid": qt_id, "name": f"견적 {code}", "num": f"{MARK}{code}", "tp": qt_type, "st": quote_st, "hrs": hrs},
|
|
)
|
|
await conn.execute(
|
|
text(
|
|
"INSERT INTO negotiation.sessions "
|
|
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, target_price, status, end_time) "
|
|
"VALUES (:sesid, :qid, :iid, :sup, :qtn, 1, :qtt, 100000, :st, now())"
|
|
),
|
|
{"sesid": session_id, "qid": qt_id, "iid": item_id, "sup": sup, "qtn": f"{MARK}{code}", "qtt": qt_type, "st": sess_st},
|
|
)
|
|
return session_id, qt_id
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def nego_seed(db_engine):
|
|
"""공급사 + 유저 + 세션/견적 3건(본인) + 1건(타 공급사) 시드. 세션/견적 id 를 반환."""
|
|
supplier_id = uuid.uuid4()
|
|
other_supplier_id = uuid.uuid4()
|
|
pw_hash = bcrypt.hashpw(TEST_PW.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
|
|
|
# (code, session.status, qt_type, 마감까지 시간(h), quotation.status, 소속 공급사)
|
|
specs = [
|
|
("A", 1, 2, 2, 1, supplier_id), # 협상생성 / 재견적 / +2h / 견적생성
|
|
("B", 2, 1, 1, 2, supplier_id), # 협상중 / 재협상 / +1h / 견적진행중
|
|
("C", 3, 2, 3, 2, supplier_id), # 협상완료 / 재견적 / +3h / 견적진행중
|
|
("X", 1, 1, 1, 1, other_supplier_id), # 타 공급사 → 목록/참여에서 제외/차단
|
|
]
|
|
sids, qids = {}, {}
|
|
|
|
async def _cleanup(conn):
|
|
# 대화는 세션보다 먼저 지운다(세션이 사라지면 대상을 못 고른다).
|
|
await conn.execute(text(
|
|
f"DELETE FROM negotiation.chats WHERE session_id IN "
|
|
f"(SELECT session_id FROM negotiation.sessions WHERE qt_number LIKE '{MARK}%')"
|
|
))
|
|
await conn.execute(text(f"DELETE FROM negotiation.sessions WHERE qt_number LIKE '{MARK}%'"))
|
|
await conn.execute(text(f"DELETE FROM quotation.quotations WHERE number LIKE '{MARK}%'"))
|
|
await conn.execute(text(f"DELETE FROM partner.items WHERE code LIKE '{MARK}%'"))
|
|
await conn.execute(text("DELETE FROM supplier.supplier_users WHERE id = :id"), {"id": TEST_LOGIN_ID})
|
|
await conn.execute(text("DELETE FROM partner.suppliers WHERE name = :n"), {"n": TEST_SUPPLIER_NAME})
|
|
|
|
async with db_engine.begin() as conn:
|
|
await _cleanup(conn)
|
|
await conn.execute(
|
|
text("INSERT INTO partner.suppliers (supplier_id, company_id, user_id, name) VALUES (:sid, gen_random_uuid(), gen_random_uuid(), :name)"),
|
|
{"sid": supplier_id, "name": TEST_SUPPLIER_NAME},
|
|
)
|
|
await conn.execute(
|
|
text(
|
|
"INSERT INTO supplier.supplier_users (supplier_id, id, password, name, last_accessed_at, status, role) "
|
|
"VALUES (:sid, :id, :pw, '협상담당자', now(), 1, 1)"
|
|
),
|
|
{"sid": supplier_id, "id": TEST_LOGIN_ID, "pw": pw_hash},
|
|
)
|
|
for spec in specs:
|
|
code = spec[0]
|
|
sids[code], qids[code] = await _seed_case(conn, *spec)
|
|
|
|
yield {"supplier_id": supplier_id, "sids": sids, "qids": qids}
|
|
|
|
async with db_engine.begin() as conn:
|
|
await _cleanup(conn)
|
|
|
|
|
|
async def _login_token(client):
|
|
r = await client.post("/v1/auth/login", json={"id": TEST_LOGIN_ID, "pw": TEST_PW})
|
|
return r.json()["access_token"]
|
|
|
|
|
|
async def _list(client, token, **params):
|
|
return await client.get("/v1/negotiation/sessions", headers={"Authorization": f"Bearer {token}"}, params=params)
|
|
|
|
|
|
async def _participate(client, token, session_id):
|
|
return await client.post(f"/v1/negotiation/sessions/{session_id}/participate", headers={"Authorization": f"Bearer {token}"})
|
|
|
|
|
|
async def _reject(client, token, session_id, reason):
|
|
return await client.post(
|
|
f"/v1/negotiation/sessions/{session_id}/reject",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
json={"reject_reason": reason},
|
|
)
|
|
|
|
|
|
async def _session_reject(db_engine, session_id):
|
|
async with db_engine.begin() as conn:
|
|
return (await conn.execute(
|
|
text("SELECT status, reject_reason FROM negotiation.sessions WHERE session_id = :sid"),
|
|
{"sid": session_id},
|
|
)).first()
|
|
|
|
|
|
async def _session_status(db_engine, session_id):
|
|
async with db_engine.begin() as conn:
|
|
return (await conn.execute(text("SELECT status FROM negotiation.sessions WHERE session_id = :sid"), {"sid": session_id})).scalar()
|
|
|
|
|
|
async def _quotation_status(db_engine, qt_id):
|
|
async with db_engine.begin() as conn:
|
|
return (await conn.execute(text("SELECT status FROM quotation.quotations WHERE qt_id = :qid"), {"qid": qt_id})).scalar()
|
|
|
|
|
|
# ---- 목록 -------------------------------------------------------------------
|
|
async def test_list_returns_only_own_supplier_sessions(client, nego_seed):
|
|
token = await _login_token(client)
|
|
body = (await _list(client, token)).json()
|
|
assert body["result"]["success"] is True
|
|
assert body["total"] == 3 # 본인 공급사 3건만 (타 공급사 X 제외)
|
|
one = next(i for i in body["items"] if i["item_code"] == f"{MARK}B")
|
|
assert one["session_status"] == 2 and one["qt_type"] == 1
|
|
assert one["model_name"] == "MODEL-B" and one["maker_name"] == "테스트제조사"
|
|
assert one["session_id"] and one["qt_end_time"]
|
|
|
|
|
|
async def test_list_filter_status(client, nego_seed):
|
|
token = await _login_token(client)
|
|
body = (await _list(client, token, status=2)).json()
|
|
assert body["total"] == 1 and body["items"][0]["item_code"] == f"{MARK}B"
|
|
|
|
|
|
async def test_list_shows_closed_quotation_created_session_as_not_participated(client, db_engine, nego_seed):
|
|
"""검증: 견적이 마감(3)된 뒤에도 세션이 협상생성(1)으로 남아 있는 건(마감 일괄정리 이후 생성 등).
|
|
기대결과: 목록 상태는 미참여(4) — '협상 대기'로 새지 않고, status=1 필터에서도 빠지고 status=4 필터에 잡힌다."""
|
|
async with db_engine.begin() as conn:
|
|
await _seed_case(conn, "CLOSED1", 1, 2, -1, 3, nego_seed["supplier_id"])
|
|
token = await _login_token(client)
|
|
|
|
listed = next(i for i in (await _list(client, token)).json()["items"] if i["item_code"] == f"{MARK}CLOSED1")
|
|
assert listed["session_status"] == 4
|
|
|
|
waiting = (await _list(client, token, status=1)).json()
|
|
assert waiting["total"] == 1 and {i["item_code"] for i in waiting["items"]} == {f"{MARK}A"}
|
|
assert f"{MARK}CLOSED1" in {i["item_code"] for i in (await _list(client, token, status=4)).json()["items"]}
|
|
|
|
|
|
async def test_list_shows_deadline_passed_created_session_as_not_participated(client, db_engine, nego_seed):
|
|
"""검증: 견적은 아직 진행중(2)인데 마감시간(end_time)만 지난 협상생성 세션.
|
|
기대결과: 미참여(4) — 참여/입장이 막히는 건이라 목록도 같은 상태로 보인다(DB 값은 그대로)."""
|
|
async with db_engine.begin() as conn:
|
|
session_id, _ = await _seed_case(conn, "OVERDUE", 1, 2, -3, 2, nego_seed["supplier_id"])
|
|
token = await _login_token(client)
|
|
|
|
listed = next(i for i in (await _list(client, token)).json()["items"] if i["item_code"] == f"{MARK}OVERDUE")
|
|
assert listed["session_status"] == 4
|
|
assert await _session_status(db_engine, session_id) == 1 # 목록은 파생 표시만, 쓰기는 하지 않는다
|
|
|
|
|
|
async def test_list_marks_stale_round_not_renegotiable(client, db_engine, nego_seed):
|
|
"""검증: 개찰(결렬) 마감된 1차 견적에 2차가 이미 생성돼 있는 체인.
|
|
기대결과: renegotiable False — 다음 라운드가 있으면 재협상 요청 대상이 아니다(체인 최대 차수 판정)."""
|
|
async with db_engine.begin() as conn:
|
|
await _seed_case(conn, "CHAIN", 3, 2, -2, 3, nego_seed["supplier_id"])
|
|
await conn.execute(text(
|
|
f"UPDATE quotation.quotations SET close_reason = 5 WHERE number = '{MARK}CHAIN'"))
|
|
# 같은 견적번호의 2차 — 번호가 같아야 체인으로 묶인다.
|
|
await conn.execute(text(
|
|
"INSERT INTO quotation.quotations (qt_id, user_id, qt_setting_id, version_id, name, number, type, status, round, start_time, end_time) "
|
|
f"VALUES (gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), '견적 CHAIN 2차', '{MARK}CHAIN', 2, 2, 2, now(), now() + make_interval(hours => 2))"))
|
|
token = await _login_token(client)
|
|
listed = next(i for i in (await _list(client, token)).json()["items"] if i["item_code"] == f"{MARK}CHAIN")
|
|
assert listed["result"] == 3 and listed["renegotiable"] is False
|
|
|
|
|
|
async def test_list_has_chat_flags_sessions_with_history(client, db_engine, nego_seed):
|
|
"""검증: 대화 이력이 있는 세션과 없는 세션의 has_chat.
|
|
기대결과: 이력 있는 건만 True — 종료 건의 '결과 보기' 노출이 이 값으로 갈린다."""
|
|
async with db_engine.begin() as conn:
|
|
await conn.execute(
|
|
text("INSERT INTO negotiation.chats (session_id, seq, sender, target_price) VALUES (:sid, 1, 1, 0)"),
|
|
{"sid": nego_seed["sids"]["C"]},
|
|
)
|
|
token = await _login_token(client)
|
|
by_code = {i["item_code"]: i["has_chat"] for i in (await _list(client, token)).json()["items"]}
|
|
assert by_code[f"{MARK}C"] is True
|
|
assert by_code[f"{MARK}A"] is False
|
|
|
|
|
|
async def test_list_filter_qt_type(client, nego_seed):
|
|
token = await _login_token(client)
|
|
body = (await _list(client, token, qt_type=2)).json()
|
|
assert {i["item_code"] for i in body["items"]} == {f"{MARK}A", f"{MARK}C"}
|
|
|
|
|
|
async def test_list_default_sort_groups_actionable_first(client, nego_seed):
|
|
# 기본 정렬(order 미지정): '할 일'(협상생성 A·협상중 B) 우선 → 마감 임박순, 종료(협상완료 C)는 하단.
|
|
token = await _login_token(client)
|
|
default = [i["item_code"] for i in (await _list(client, token)).json()["items"]]
|
|
# 액션 그룹 임박순(B +1h → A +2h) 뒤에 종료(C). C 는 마감이 가장 멀어도(+3h) 최하단 고정.
|
|
assert default == [f"{MARK}B", f"{MARK}A", f"{MARK}C"]
|
|
|
|
|
|
async def test_list_order_param_switches_to_global_sort(client, nego_seed):
|
|
# order 를 명시하면 그룹을 무시하고 전체를 마감 기준 한 줄로 정렬한다.
|
|
token = await _login_token(client)
|
|
asc = [i["item_code"] for i in (await _list(client, token, order="asc")).json()["items"]]
|
|
desc = [i["item_code"] for i in (await _list(client, token, order="desc")).json()["items"]]
|
|
|
|
# asc: 전체 마감 임박순 (B +1h → A +2h → C +3h)
|
|
assert asc == [f"{MARK}B", f"{MARK}A", f"{MARK}C"]
|
|
# desc: 전체 마감 여유순 — 종료(C)라도 마감이 가장 멀면 최상단으로 올라온다(그룹 무시 증거).
|
|
assert desc == [f"{MARK}C", f"{MARK}A", f"{MARK}B"]
|
|
|
|
|
|
async def test_list_pagination(client, nego_seed):
|
|
token = await _login_token(client)
|
|
body = (await _list(client, token, page=1, page_size=2)).json()
|
|
assert body["total"] == 3 and len(body["items"]) == 2
|
|
|
|
|
|
async def test_list_requires_auth(client):
|
|
assert (await client.get("/v1/negotiation/sessions")).status_code in (401, 403)
|
|
|
|
|
|
# ---- 검색(keyword) ----------------------------------------------------------
|
|
async def test_search_by_qt_number_and_item_code(client, nego_seed):
|
|
"""검증: 견적번호/상품코드가 같은 값(PYTESTNEGO-B)으로 검색.
|
|
기대결과: B 1건만, total 도 1(카운트도 같은 필터 적용)."""
|
|
token = await _login_token(client)
|
|
body = (await _list(client, token, keyword=f"{MARK}B")).json()
|
|
assert body["total"] == 1
|
|
assert [i["item_code"] for i in body["items"]] == [f"{MARK}B"]
|
|
|
|
|
|
async def test_search_by_item_name(client, nego_seed):
|
|
"""검증: 상품명 일부('상품 A')로 검색.
|
|
기대결과: A 1건만."""
|
|
token = await _login_token(client)
|
|
body = (await _list(client, token, keyword="상품 A")).json()
|
|
assert {i["item_code"] for i in body["items"]} == {f"{MARK}A"}
|
|
|
|
|
|
async def test_search_prefix_matches_all_own(client, nego_seed):
|
|
"""검증: 공통 prefix(PYTESTNEGO)로 검색.
|
|
기대결과: 본인 공급사 3건 전부(타 공급사 X 는 제외 유지)."""
|
|
token = await _login_token(client)
|
|
body = (await _list(client, token, keyword=MARK.rstrip("-"))).json()
|
|
assert body["total"] == 3
|
|
|
|
|
|
async def test_search_case_insensitive(client, nego_seed):
|
|
"""검증: 소문자로 검색(pytestnego-c).
|
|
기대결과: ILIKE 라 대소문자 무시하고 C 매칭."""
|
|
token = await _login_token(client)
|
|
body = (await _list(client, token, keyword=f"{MARK}c".lower())).json()
|
|
assert {i["item_code"] for i in body["items"]} == {f"{MARK}C"}
|
|
|
|
|
|
async def test_search_no_match_returns_empty(client, nego_seed):
|
|
"""검증: 어디에도 없는 검색어.
|
|
기대결과: 0건, total 0."""
|
|
token = await _login_token(client)
|
|
body = (await _list(client, token, keyword="존재하지않는검색어zzz")).json()
|
|
assert body["total"] == 0 and body["items"] == []
|
|
|
|
|
|
async def test_search_wildcard_is_escaped(client, nego_seed):
|
|
"""검증: ILIKE 와일드카드('%')를 그대로 검색 — 패턴으로 새면 전건 매칭될 위험.
|
|
기대결과: escape 되어 리터럴 '%' 로 취급 → 매칭 0건."""
|
|
token = await _login_token(client)
|
|
body = (await _list(client, token, keyword="%")).json()
|
|
assert body["total"] == 0
|
|
|
|
|
|
# ---- 참여 -------------------------------------------------------------------
|
|
async def test_participate_success(client, nego_seed, db_engine):
|
|
token = await _login_token(client)
|
|
sid, qid = nego_seed["sids"]["A"], nego_seed["qids"]["A"] # 협상생성
|
|
r = await _participate(client, token, sid)
|
|
assert r.json()["result"]["success"] is True
|
|
assert r.json()["session_id"] == str(sid)
|
|
assert await _session_status(db_engine, sid) == 2 # 협상중
|
|
assert await _quotation_status(db_engine, qid) == 2 # 견적진행중
|
|
|
|
|
|
async def test_participate_forbidden_other_supplier(client, nego_seed):
|
|
token = await _login_token(client)
|
|
r = await _participate(client, token, nego_seed["sids"]["X"]) # 타 공급사 세션
|
|
assert r.json()["result"]["code"] == 1300 # NEGO_FORBIDDEN
|
|
|
|
|
|
async def test_participate_not_participable(client, nego_seed, db_engine):
|
|
token = await _login_token(client)
|
|
sid = nego_seed["sids"]["A"]
|
|
async with db_engine.begin() as conn:
|
|
await conn.execute(text("UPDATE negotiation.sessions SET status = 4 WHERE session_id = :sid"), {"sid": sid}) # 미참여
|
|
r = await _participate(client, token, sid)
|
|
assert r.json()["result"]["code"] == 1301 # NEGO_NOT_PARTICIPABLE
|
|
|
|
|
|
async def test_participate_quotation_closed(client, nego_seed, db_engine):
|
|
token = await _login_token(client)
|
|
sid, qid = nego_seed["sids"]["A"], nego_seed["qids"]["A"]
|
|
async with db_engine.begin() as conn:
|
|
await conn.execute(text("UPDATE quotation.quotations SET status = 3 WHERE qt_id = :qid"), {"qid": qid}) # 견적마감
|
|
r = await _participate(client, token, sid)
|
|
assert r.json()["result"]["code"] == 1302 # NEGO_QUOTATION_CLOSED
|
|
|
|
|
|
async def test_participate_deadline_passed_sets_not_participated(client, nego_seed, db_engine):
|
|
token = await _login_token(client)
|
|
sid, qid = nego_seed["sids"]["A"], nego_seed["qids"]["A"] # 협상생성
|
|
async with db_engine.begin() as conn:
|
|
await conn.execute(text("UPDATE quotation.quotations SET end_time = now() - make_interval(hours => 1) WHERE qt_id = :qid"), {"qid": qid})
|
|
r = await _participate(client, token, sid)
|
|
assert r.json()["result"]["code"] == 1303 # NEGO_DEADLINE_PASSED
|
|
assert await _session_status(db_engine, sid) == 4 # 협상생성이었으므로 미참여로 정리됨
|
|
assert await _quotation_status(db_engine, qid) == 1 # 견적은 변경 안 됨
|
|
|
|
|
|
async def test_participate_in_progress_no_state_change(client, nego_seed, db_engine):
|
|
token = await _login_token(client)
|
|
sid, qid = nego_seed["sids"]["B"], nego_seed["qids"]["B"] # 이미 협상중
|
|
r = await _participate(client, token, sid)
|
|
assert r.json()["result"]["success"] is True
|
|
assert r.json()["session_id"] == str(sid)
|
|
assert await _session_status(db_engine, sid) == 2 # 무변경 (협상중 유지)
|
|
assert await _quotation_status(db_engine, qid) == 2 # 무변경
|
|
|
|
|
|
async def test_participate_done_bypasses_quotation_closed(client, nego_seed, db_engine):
|
|
"""협상완료 세션은 결과 열람용 재진입이므로 견적마감·마감시간이 지나도 참여(진입) 가능."""
|
|
token = await _login_token(client)
|
|
sid, qid = nego_seed["sids"]["C"], nego_seed["qids"]["C"] # 협상완료
|
|
async with db_engine.begin() as conn:
|
|
await conn.execute(
|
|
text("UPDATE quotation.quotations SET status = 3, end_time = now() - make_interval(hours => 1) WHERE qt_id = :qid"),
|
|
{"qid": qid},
|
|
) # 견적마감 + 마감시간 경과
|
|
r = await _participate(client, token, sid)
|
|
assert r.json()["result"]["success"] is True
|
|
assert r.json()["session_id"] == str(sid)
|
|
assert await _session_status(db_engine, sid) == 3 # 무변경 (협상완료 유지)
|
|
assert await _quotation_status(db_engine, qid) == 3 # 무변경 (견적마감 유지)
|
|
|
|
|
|
async def test_participate_session_not_found(client, nego_seed):
|
|
token = await _login_token(client)
|
|
r = await _participate(client, token, str(uuid.uuid4()))
|
|
assert r.json()["result"]["code"] == 1304 # NEGO_NOT_FOUND
|
|
|
|
|
|
# ---- 거부 -------------------------------------------------------------------
|
|
async def test_reject_success(client, nego_seed, db_engine):
|
|
token = await _login_token(client)
|
|
sid = nego_seed["sids"]["B"] # 협상중 → 거부 가능
|
|
r = await _reject(client, token, sid, "단종 상품입니다")
|
|
assert r.json()["result"]["success"] is True
|
|
assert r.json()["session_id"] == str(sid)
|
|
status, reason = await _session_reject(db_engine, sid)
|
|
assert status == 5 and reason == "단종 상품입니다" # REJECTED + 사유 저장
|
|
|
|
|
|
async def test_reject_with_price_and_opinion(client, nego_seed, db_engine):
|
|
# 채팅 내 협상 거부 경로 — 사유 외에 공급 희망 가격과 의견까지 함께 남긴다.
|
|
token = await _login_token(client)
|
|
sid = nego_seed["sids"]["B"]
|
|
r = await client.post(
|
|
f"/v1/negotiation/sessions/{sid}/reject",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
json={"reject_reason": "품절", "reject_price": 88000, "opinion": "대체품으로 재견적 부탁드립니다"},
|
|
)
|
|
assert r.json()["result"]["success"] is True
|
|
async with db_engine.begin() as conn:
|
|
row = (await conn.execute(
|
|
text("SELECT status, reject_reason, reject_price, custom FROM negotiation.sessions WHERE session_id = :sid"),
|
|
{"sid": sid},
|
|
)).first()
|
|
assert row.status == 5 and row.reject_reason == "품절"
|
|
assert row.reject_price == 88000
|
|
assert row.custom["opinion"] == "대체품으로 재견적 부탁드립니다"
|
|
|
|
|
|
async def test_reject_without_price_keeps_null(client, nego_seed, db_engine):
|
|
# 목록 거부 경로 — 가격이 없으면 reject_price 를 건드리지 않는다.
|
|
token = await _login_token(client)
|
|
sid = nego_seed["sids"]["B"]
|
|
r = await _reject(client, token, sid, "단종")
|
|
assert r.json()["result"]["success"] is True
|
|
async with db_engine.begin() as conn:
|
|
row = (await conn.execute(
|
|
text("SELECT reject_price, custom FROM negotiation.sessions WHERE session_id = :sid"),
|
|
{"sid": sid},
|
|
)).first()
|
|
assert row.reject_price is None and row.custom is None
|
|
|
|
|
|
async def test_list_returns_reject_detail(client, nego_seed):
|
|
# 거부 제출 내역은 대화에 남지 않는다 — 목록이 사유·희망가를 실어야 '거부 내역'을 열람할 수 있다.
|
|
token = await _login_token(client)
|
|
sid = nego_seed["sids"]["B"]
|
|
await client.post(
|
|
f"/v1/negotiation/sessions/{sid}/reject",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
json={"reject_reason": "품절", "reject_price": 77000, "opinion": "재고 확보 후 연락드리겠습니다"},
|
|
)
|
|
items = (await _list(client, token)).json()["items"]
|
|
row = next(i for i in items if i["session_id"] == str(sid))
|
|
assert row["reject_reason"] == "품절"
|
|
assert row["reject_price"] == 77000
|
|
assert row["custom"]["opinion"] == "재고 확보 후 연락드리겠습니다"
|
|
|
|
|
|
async def test_list_reject_detail_empty_for_active(client, nego_seed):
|
|
# 거부 건이 아니면 빈 값 — 프론트가 '거부 내역' 버튼 노출을 상태로만 판단하므로 값이 새면 안 된다.
|
|
token = await _login_token(client)
|
|
items = (await _list(client, token)).json()["items"]
|
|
row = next(i for i in items if i["session_id"] == str(nego_seed["sids"]["A"]))
|
|
assert row["reject_reason"] == ""
|
|
assert row.get("reject_price") is None
|
|
|
|
|
|
async def test_reject_empty_reason(client, nego_seed):
|
|
token = await _login_token(client)
|
|
r = await _reject(client, token, nego_seed["sids"]["B"], " ") # 공백만 → 사유 없음
|
|
assert r.json()["result"]["code"] == 101 # INVALID_REQUEST_DATA
|
|
|
|
|
|
async def test_reject_forbidden_other_supplier(client, nego_seed):
|
|
token = await _login_token(client)
|
|
r = await _reject(client, token, nego_seed["sids"]["X"], "사유") # 타 공급사 세션
|
|
assert r.json()["result"]["code"] == 1300 # NEGO_FORBIDDEN
|
|
|
|
|
|
async def test_reject_not_participable_when_done(client, nego_seed):
|
|
token = await _login_token(client)
|
|
r = await _reject(client, token, nego_seed["sids"]["C"], "사유") # 협상완료(3) → 거부 불가
|
|
assert r.json()["result"]["code"] == 1301 # NEGO_NOT_PARTICIPABLE
|
|
|
|
|
|
async def test_reject_session_not_found(client, nego_seed):
|
|
token = await _login_token(client)
|
|
r = await _reject(client, token, str(uuid.uuid4()), "사유")
|
|
assert r.json()["result"]["code"] == 1304 # NEGO_NOT_FOUND
|
|
|
|
|
|
async def test_reject_requires_auth(client, nego_seed):
|
|
sid = nego_seed["sids"]["B"]
|
|
r = await client.post(f"/v1/negotiation/sessions/{sid}/reject", json={"reject_reason": "사유"})
|
|
assert r.status_code in (401, 403)
|
|
|
|
|
|
# ---- 결과 필터(result) ------------------------------------------------------
|
|
# 마감(CLOSED) + 마감사유/낙찰자로 낙찰(1)·미낙찰(2)·결렬(3)을 만들고 result= 로 거른다.
|
|
# nego_seed 의 공급사/로그인을 재사용하고, MARK prefix 라 픽스처 teardown 이 함께 정리한다.
|
|
async def _seed_result_row(engine, *, supplier_id, code, close_reason, winner_id):
|
|
import uuid as _uuid
|
|
item_id, qt_id, session_id = _uuid.uuid4(), _uuid.uuid4(), _uuid.uuid4()
|
|
async with engine.begin() as conn:
|
|
await conn.execute(
|
|
text("INSERT INTO partner.items (item_id, company_id, user_id, name, code, model_name, manufacturer) "
|
|
"VALUES (:iid, gen_random_uuid(), gen_random_uuid(), :name, :code, 'M', '제조사')"),
|
|
{"iid": item_id, "name": f"상품 {code}", "code": f"{MARK}{code}"},
|
|
)
|
|
await conn.execute(
|
|
text("INSERT INTO quotation.quotations "
|
|
"(qt_id, user_id, qt_setting_id, version_id, name, number, type, status, close_reason, "
|
|
" preferred_sp_id, round, start_time, end_time) VALUES "
|
|
"(:qid, gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), :name, :num, 2, 3, :cr, "
|
|
" :win, 1, now() - make_interval(hours => 2), now() - make_interval(hours => 1))"),
|
|
{"qid": qt_id, "name": f"견적 {code}", "num": f"{MARK}{code}", "cr": close_reason, "win": winner_id},
|
|
)
|
|
await conn.execute(
|
|
text("INSERT INTO negotiation.sessions "
|
|
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, "
|
|
" target_price, status, bid_price, end_time) VALUES "
|
|
"(:sid, :qid, :iid, :sup, :num, 1, 2, 100000, 3, 95000, now() - make_interval(hours => 1))"),
|
|
{"sid": session_id, "qid": qt_id, "iid": item_id, "sup": supplier_id, "num": f"{MARK}{code}"},
|
|
)
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def result_rows(nego_seed, db_engine):
|
|
"""nego_seed 공급사에 낙찰/미낙찰/결렬 각 1건을 추가한다(개찰 5=OPEN_PRICE, 1=AWARDED)."""
|
|
sup = nego_seed["supplier_id"]
|
|
await _seed_result_row(db_engine, supplier_id=sup, code="RWON", close_reason=1, winner_id=sup) # 낙찰(나)
|
|
await _seed_result_row(db_engine, supplier_id=sup, code="RLOST", close_reason=1, winner_id=uuid.uuid4()) # 미낙찰(남)
|
|
await _seed_result_row(db_engine, supplier_id=sup, code="ROPEN", close_reason=5, winner_id=None) # 결렬(개찰)
|
|
return nego_seed
|
|
|
|
|
|
async def test_result_filter_won(client, result_rows):
|
|
"""검증: result=1(낙찰)로 필터. 기대결과: 낙찰 건만, total=1."""
|
|
token = await _login_token(client)
|
|
body = (await _list(client, token, result=1)).json()
|
|
assert body["total"] == 1
|
|
assert body["items"][0]["item_code"] == f"{MARK}RWON"
|
|
assert body["items"][0]["result"] == 1
|
|
|
|
|
|
async def test_result_filter_lost(client, result_rows):
|
|
"""검증: result=2(미낙찰)로 필터. 기대결과: 미낙찰 건만."""
|
|
token = await _login_token(client)
|
|
body = (await _list(client, token, result=2)).json()
|
|
assert {i["item_code"] for i in body["items"]} == {f"{MARK}RLOST"}
|
|
assert body["items"][0]["result"] == 2
|
|
|
|
|
|
async def test_result_filter_open(client, result_rows):
|
|
"""검증: result=3(결렬)로 필터. 기대결과: 개찰 결렬 건만 + 재협상 대상(renegotiable=True)."""
|
|
token = await _login_token(client)
|
|
body = (await _list(client, token, result=3)).json()
|
|
assert {i["item_code"] for i in body["items"]} == {f"{MARK}ROPEN"}
|
|
assert body["items"][0]["result"] == 3
|
|
assert body["items"][0]["renegotiable"] is True
|
|
|
|
|
|
async def test_result_filter_composes_with_paging(client, result_rows):
|
|
"""검증: 결과 필터가 total(페이징)에 반영. 기대결과: result=1 이면 total=1(전체 목록과 별개)."""
|
|
token = await _login_token(client)
|
|
all_total = (await _list(client, token)).json()["total"]
|
|
won_total = (await _list(client, token, result=1)).json()["total"]
|
|
assert won_total == 1 and all_total > won_total
|