365 lines
17 KiB
Python
365 lines
17 KiB
Python
"""공급사 재협상 요청 심사(IMK #15) — RenegotiationService 단위 테스트.
|
|
|
|
요청 자체는 공급사 포털이 sessions.custom.renegotiation 에 기록한다(여기선 조회·승인·반려만).
|
|
승인은 새 로직이 아니라 기존 regenerate_quotation 을 호출하므로, 라운드 생성 machinery 는
|
|
스텁 QuotationService 로 격리하고 #15 고유 계약만 본다:
|
|
· 목록 = 회사 스코프 + 상태 필터 (남의 회사 요청은 안 보임)
|
|
· 승인 = 대기 요청만 → APPROVED 박제 + next_quotation_id 저장, 요청 공급사는 항상 재생성에 포함
|
|
· 승인 재생성 실패 → 상태 PENDING 유지(성급히 APPROVED 로 넘기지 않음)
|
|
· 반려 = 대기 요청만 → REJECTED + 사유(memo) 저장
|
|
· 대기 아닌 요청(이미 승인/반려)엔 승인·반려 재시도 거부(멱등 가드)
|
|
|
|
세션의 custom.renegotiation 은 포털에서만 생기는 값이라 SQL 로 직접 넣는다.
|
|
"""
|
|
import json
|
|
import uuid
|
|
from datetime import datetime
|
|
|
|
import pytest_asyncio
|
|
from sqlalchemy import text
|
|
|
|
from common.enums import (
|
|
CloseReason,
|
|
ErrorType,
|
|
QuotationStatus,
|
|
QuotationType,
|
|
RenegotiationStatus,
|
|
SessionStatus,
|
|
UserRole,
|
|
)
|
|
from common.models.gmodel import PageParams
|
|
from crud.renegotiation_crud import RenegotiationCRUD
|
|
from router.v1.quotation.protocol import Res_CreateQuotation
|
|
from services.renegotiation_service import RenegotiationService
|
|
|
|
PAST = datetime(2020, 1, 1)
|
|
PG = PageParams(1, 20)
|
|
|
|
|
|
class _StubQuotation:
|
|
"""regenerate_quotation 을 대신한다 — 인자를 기록하고 정해진 결과만 돌려준다.
|
|
ok=False 면 재생성 실패를 흉내낸다(승인이 상태를 넘기면 안 되는 경로 검증)."""
|
|
|
|
def __init__(self, *, ok=True, qt_id=None):
|
|
self.ok = ok
|
|
self.qt_id = qt_id or uuid.uuid4()
|
|
self.calls = []
|
|
|
|
async def regenerate_quotation(self, qt_id, company_id, supplier_ids, user_id=None, role=None, regen_label=None):
|
|
self.calls.append(
|
|
{"qt_id": qt_id, "company_id": company_id, "supplier_ids": list(supplier_ids),
|
|
"user_id": user_id, "role": role, "regen_label": regen_label}
|
|
)
|
|
res = Res_CreateQuotation()
|
|
if self.ok:
|
|
res.qt_id = self.qt_id
|
|
else:
|
|
res.result.SetResult(ErrorType.FAIL)
|
|
res.msg = "stub 재생성 실패"
|
|
return res
|
|
|
|
|
|
# ===== 목록 =====
|
|
async def test_list_scoped_to_company(db_engine, company_id, other_company_id):
|
|
"""검증: 내 회사 대기요청 1건 + 남의 회사 대기요청 1건이 있을 때 내 회사로 목록 조회.
|
|
기대결과: 내 회사 건만(total=1) 나오고, 남의 회사 세션 id 는 결과에 없다."""
|
|
mine = await _seed_request(db_engine, company_id, number="R-MINE", renego=_pending())
|
|
await _seed_request(db_engine, other_company_id, number="R-THEIRS", renego=_pending())
|
|
|
|
res = await _service().list_requests(company_id, str(uuid.uuid4()), UserRole.OWNER.value, None, PG)
|
|
|
|
assert res.result.success is True
|
|
assert res.total == 1
|
|
assert [r.session_id for r in res.requests] == [mine["session_id"]]
|
|
|
|
|
|
async def test_list_filters_by_status(db_engine, company_id):
|
|
"""검증: 같은 회사에 대기(PENDING)·반려(REJECTED) 요청을 하나씩 두고 status=1(대기)로 필터.
|
|
기대결과: 대기 건만 반환(total=1)."""
|
|
pending = await _seed_request(db_engine, company_id, number="R-P", renego=_pending())
|
|
await _seed_request(db_engine, company_id, number="R-R", renego=_decided(RenegotiationStatus.REJECTED.value))
|
|
|
|
res = await _service().list_requests(company_id, str(uuid.uuid4()), UserRole.OWNER.value, RenegotiationStatus.PENDING.value, PG)
|
|
|
|
assert res.total == 1
|
|
assert res.requests[0].session_id == pending["session_id"]
|
|
assert res.requests[0].status == RenegotiationStatus.PENDING.value
|
|
|
|
|
|
async def test_list_shows_decider_name(db_engine, company_id):
|
|
"""검증: 처리(승인/반려)된 요청의 decided_by(담당자 user_id)로 담당자 이름을 조인해 내려준다.
|
|
기대결과: 목록 항목의 decided_by_name 이 그 담당자 이름."""
|
|
decider_id = uuid.uuid4()
|
|
await _seed_user(db_engine, company_id, decider_id, "김담당")
|
|
renego = {**_decided(RenegotiationStatus.APPROVED.value), "decided_by": str(decider_id)}
|
|
await _seed_request(db_engine, company_id, number="R-WHO", renego=renego)
|
|
|
|
res = await _service().list_requests(company_id, str(uuid.uuid4()), UserRole.OWNER.value, None, PG)
|
|
|
|
assert res.requests[0].decided_by_name == "김담당"
|
|
|
|
|
|
async def test_list_shows_all_with_can_act_flags(db_engine, company_id):
|
|
"""검증: 같은 회사에 서로 다른 작성자의 요청 2건. 리스트엔 전체가 보이되, 처리 권한은 can_act 로 온다.
|
|
기대결과: 일반관리자(A)는 둘 다 보이고(total=2) can_act 는 자기(A) 것만 True. OWNER 는 전부 True."""
|
|
a = await _seed_request(db_engine, company_id, number="R-A", renego=_pending())
|
|
b = await _seed_request(db_engine, company_id, number="R-B", renego=_pending())
|
|
|
|
res = await _service().list_requests(company_id, a["user_id"], UserRole.USER.value, None, PG)
|
|
assert res.total == 2
|
|
can = {r.session_id: r.can_act for r in res.requests}
|
|
assert can[a["session_id"]] is True
|
|
assert can[b["session_id"]] is False
|
|
|
|
owner = await _service().list_requests(company_id, a["user_id"], UserRole.OWNER.value, None, PG)
|
|
assert all(r.can_act for r in owner.requests)
|
|
|
|
|
|
# ===== 승인 =====
|
|
async def test_approve_transitions_and_persists(db_engine, company_id):
|
|
"""검증: 대기 요청을 승인(재생성 성공 스텁, 승인메모 첨부).
|
|
기대결과: APPROVED + next_quotation_id 저장 + memo 저장, 재생성엔 요청 공급사가 포함돼 호출된다."""
|
|
seed = await _seed_request(db_engine, company_id, number="R-OK", renego=_pending())
|
|
stub = _StubQuotation(ok=True)
|
|
svc = _service(stub)
|
|
|
|
req = _approve_req(memo="조건 재검토 승인")
|
|
res = await svc.approve(company_id, seed["user_id"], UserRole.USER.value, seed["session_id"], req)
|
|
|
|
assert res.result.success is True
|
|
assert res.status == RenegotiationStatus.APPROVED.value
|
|
assert res.next_quotation_id == str(stub.qt_id)
|
|
# 재생성은 정확히 1번, 요청 공급사를 포함해서 호출
|
|
assert len(stub.calls) == 1
|
|
assert seed["supplier_id"] in stub.calls[0]["supplier_ids"]
|
|
assert stub.calls[0]["qt_id"] == seed["quotation_id"]
|
|
# 재생성 견적 타이틀 마킹용 라벨을 넘긴다(수동 재생성과 구분).
|
|
assert stub.calls[0]["regen_label"] == "재협상 요청 재생성"
|
|
|
|
saved = await _renego_of(db_engine, seed["session_id"])
|
|
assert saved["status"] == RenegotiationStatus.APPROVED.value
|
|
assert saved["next_quotation_id"] == str(stub.qt_id)
|
|
assert saved["memo"] == "조건 재검토 승인"
|
|
|
|
|
|
async def test_approve_includes_extra_suppliers(db_engine, company_id):
|
|
"""검증: 승인 시 요청자 외 추가 공급사(supplier_ids)를 함께 지정.
|
|
기대결과: 재생성 호출의 공급사 집합에 요청자 + 추가 공급사가 모두 들어간다(중복 없이)."""
|
|
seed = await _seed_request(db_engine, company_id, number="R-MULTI", renego=_pending())
|
|
extra = str(uuid.uuid4())
|
|
stub = _StubQuotation(ok=True)
|
|
|
|
req = _approve_req(supplier_ids=[extra, seed["supplier_id"]]) # 요청자 중복 포함
|
|
await _service(stub).approve(company_id, seed["user_id"], UserRole.USER.value, seed["session_id"], req)
|
|
|
|
got = set(stub.calls[0]["supplier_ids"])
|
|
assert got == {seed["supplier_id"], extra}
|
|
|
|
|
|
async def test_approve_blocks_when_not_pending(db_engine, company_id):
|
|
"""검증: 이미 승인된(APPROVED) 요청에 승인 재시도(멱등 가드).
|
|
기대결과: 거부(INVALID_REQUEST_DATA) + 재생성 미호출."""
|
|
seed = await _seed_request(
|
|
db_engine, company_id, number="R-DONE", renego=_decided(RenegotiationStatus.APPROVED.value)
|
|
)
|
|
stub = _StubQuotation(ok=True)
|
|
|
|
res = await _service(stub).approve(company_id, str(uuid.uuid4()), UserRole.USER.value, seed["session_id"], _approve_req())
|
|
|
|
assert res.result.success is False
|
|
assert res.result.code == ErrorType.INVALID_REQUEST_DATA.value
|
|
assert stub.calls == []
|
|
|
|
|
|
async def test_approve_keeps_pending_when_regenerate_fails(db_engine, company_id):
|
|
"""검증: 대기 요청 승인 중 재생성이 실패(스텁 ok=False).
|
|
기대결과: 실패 반환 + 상태는 PENDING 그대로(성급히 APPROVED 로 넘기지 않음)."""
|
|
seed = await _seed_request(db_engine, company_id, number="R-FAIL", renego=_pending())
|
|
stub = _StubQuotation(ok=False)
|
|
|
|
res = await _service(stub).approve(company_id, seed["user_id"], UserRole.USER.value, seed["session_id"], _approve_req())
|
|
|
|
assert res.result.success is False
|
|
saved = await _renego_of(db_engine, seed["session_id"])
|
|
assert saved["status"] == RenegotiationStatus.PENDING.value
|
|
assert "next_quotation_id" not in saved or saved["next_quotation_id"] is None
|
|
|
|
|
|
async def test_approve_other_company_not_found(db_engine, company_id, other_company_id):
|
|
"""검증: 남의 회사 요청 세션을 내 회사 자격으로 승인 시도(IDOR).
|
|
기대결과: NOT_FOUND(회사 스코프 밖) + 재생성 미호출."""
|
|
seed = await _seed_request(db_engine, other_company_id, number="R-IDOR", renego=_pending())
|
|
stub = _StubQuotation(ok=True)
|
|
|
|
res = await _service(stub).approve(company_id, str(uuid.uuid4()), UserRole.USER.value, seed["session_id"], _approve_req())
|
|
|
|
assert res.result.success is False
|
|
assert res.result.code == ErrorType.QUOTATION_NOT_FOUND.value
|
|
assert stub.calls == []
|
|
|
|
|
|
# ===== 반려 =====
|
|
async def test_reject_transitions_and_saves_memo(db_engine, company_id):
|
|
"""검증: 대기 요청을 사유와 함께 반려.
|
|
기대결과: REJECTED + memo(반려 사유) 저장."""
|
|
seed = await _seed_request(db_engine, company_id, number="R-REJ", renego=_pending())
|
|
|
|
res = await _service().reject(company_id, seed["user_id"], UserRole.USER.value, seed["session_id"], _reject_req("단종 품목이라 불가"))
|
|
|
|
assert res.result.success is True
|
|
assert res.status == RenegotiationStatus.REJECTED.value
|
|
saved = await _renego_of(db_engine, seed["session_id"])
|
|
assert saved["status"] == RenegotiationStatus.REJECTED.value
|
|
assert saved["memo"] == "단종 품목이라 불가"
|
|
|
|
|
|
async def test_reject_blocks_when_not_pending(db_engine, company_id):
|
|
"""검증: 이미 반려된 요청에 반려 재시도.
|
|
기대결과: 거부(INVALID_REQUEST_DATA)."""
|
|
seed = await _seed_request(
|
|
db_engine, company_id, number="R-REJ2", renego=_decided(RenegotiationStatus.REJECTED.value)
|
|
)
|
|
|
|
res = await _service().reject(company_id, seed["user_id"], UserRole.USER.value, seed["session_id"], _reject_req("x"))
|
|
|
|
assert res.result.success is False
|
|
assert res.result.code == ErrorType.INVALID_REQUEST_DATA.value
|
|
|
|
|
|
# ===== 소유권 게이팅 =====
|
|
async def test_approve_forbidden_for_non_owner_user(db_engine, company_id):
|
|
"""검증: 남의 견적 재협상 요청을 일반관리자(비소유·USER)가 승인 시도.
|
|
기대결과: 거부(ACCOUNT_FORBIDDEN) + 재생성 미호출."""
|
|
seed = await _seed_request(db_engine, company_id, number="R-NOTMINE", renego=_pending())
|
|
stub = _StubQuotation(ok=True)
|
|
|
|
res = await _service(stub).approve(company_id, str(uuid.uuid4()), UserRole.USER.value, seed["session_id"], _approve_req())
|
|
|
|
assert res.result.success is False
|
|
assert res.result.code == ErrorType.ACCOUNT_FORBIDDEN.value
|
|
assert stub.calls == []
|
|
|
|
|
|
async def test_approve_allowed_for_owner(db_engine, company_id):
|
|
"""검증: 남의 견적이라도 최고관리자(OWNER)면 승인.
|
|
기대결과: 성공 + 재생성 호출."""
|
|
seed = await _seed_request(db_engine, company_id, number="R-OWNER", renego=_pending())
|
|
stub = _StubQuotation(ok=True)
|
|
|
|
res = await _service(stub).approve(company_id, str(uuid.uuid4()), UserRole.OWNER.value, seed["session_id"], _approve_req())
|
|
|
|
assert res.result.success is True
|
|
assert len(stub.calls) == 1
|
|
|
|
|
|
async def test_reject_forbidden_for_non_owner_user(db_engine, company_id):
|
|
"""검증: 남의 견적 재협상 요청을 일반관리자가 반려 시도.
|
|
기대결과: 거부(ACCOUNT_FORBIDDEN)."""
|
|
seed = await _seed_request(db_engine, company_id, number="R-REJNOT", renego=_pending())
|
|
|
|
res = await _service().reject(company_id, str(uuid.uuid4()), UserRole.USER.value, seed["session_id"], _reject_req("x"))
|
|
|
|
assert res.result.success is False
|
|
assert res.result.code == ErrorType.ACCOUNT_FORBIDDEN.value
|
|
|
|
|
|
# ===== 헬퍼 =====
|
|
def _pending():
|
|
return {
|
|
"status": RenegotiationStatus.PENDING.value,
|
|
"reason": "가격 재검토 요청",
|
|
"desired_price": 90000,
|
|
"requested_at": "2026-07-20T00:00:00+00:00",
|
|
}
|
|
|
|
|
|
def _decided(status):
|
|
return {**_pending(), "status": status, "decided_at": "2026-07-21T00:00:00+00:00", "memo": "기존 판단"}
|
|
|
|
|
|
def _approve_req(*, supplier_ids=None, memo=""):
|
|
from router.v1.renegotiation.protocol import Req_ApproveRenegotiation
|
|
|
|
return Req_ApproveRenegotiation(supplier_ids=supplier_ids or [], memo=memo)
|
|
|
|
|
|
def _reject_req(memo):
|
|
from router.v1.renegotiation.protocol import Req_RejectRenegotiation
|
|
|
|
return Req_RejectRenegotiation(memo=memo)
|
|
|
|
|
|
def _service(quotation_stub=None):
|
|
return RenegotiationService(RenegotiationCRUD(), quotation_stub or _StubQuotation())
|
|
|
|
|
|
async def _seed_user(engine, company_id, user_id, name):
|
|
"""담당자 유저 1건 시드(decided_by 이름 조인 확인용)."""
|
|
async with engine.begin() as conn:
|
|
await conn.execute(
|
|
text(
|
|
"INSERT INTO users (user_id, company_id, id, password, name, status, role, last_accessed_at) "
|
|
"VALUES (:uid, :cid, :login, 'x', :name, 1, :role, now())"
|
|
),
|
|
{"uid": user_id, "cid": uuid.UUID(company_id), "login": f"dec-{str(user_id)[:8]}", "name": name, "role": UserRole.USER.value},
|
|
)
|
|
|
|
|
|
async def _seed_request(engine, company_id, *, number, renego, close_reason=CloseReason.OPEN_PRICE.value, round_=1):
|
|
"""재협상 요청 1건 시드: 작성자(회사 스코프) + 마감견적 + custom.renegotiation 달린 세션.
|
|
목록 쿼리가 quotations→users(company_id)·items·suppliers 를 조인하므로 이들을 함께 넣는다."""
|
|
user_id, qt_id, session_id = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
|
|
supplier_id, item_id = uuid.uuid4(), uuid.uuid4()
|
|
custom = {"renegotiation": renego}
|
|
async with engine.begin() as conn:
|
|
await conn.execute(
|
|
text(
|
|
"INSERT INTO users (user_id, company_id, id, password, name, status, role, last_accessed_at) "
|
|
"VALUES (:uid, :cid, :login, 'x', '담당', 1, :role, now())"
|
|
),
|
|
{"uid": user_id, "cid": uuid.UUID(company_id), "login": f"u-{number}", "role": UserRole.USER.value},
|
|
)
|
|
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, :uid, :setting, :version, '견적', :number, :type, :status, :close_reason, "
|
|
" :round, 0, :past, :past, false)"
|
|
),
|
|
{
|
|
"qt_id": qt_id, "uid": user_id, "setting": uuid.uuid4(), "version": uuid.uuid4(),
|
|
"number": number, "type": QuotationType.REQUOTE.value, "status": QuotationStatus.CLOSED.value,
|
|
"close_reason": close_reason, "round": round_, "past": PAST,
|
|
},
|
|
)
|
|
# items·suppliers 는 목록 쿼리에서 outerjoin 이라 시드 없이도 된다(이름은 빈 문자열로 채워짐).
|
|
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, custom) VALUES "
|
|
"(:sid, :qt_id, :iid, :spid, :number, :round, :type, "
|
|
" 100000, :sstatus, 95000, :past, CAST(:custom AS JSONB))"
|
|
),
|
|
{
|
|
"sid": session_id, "qt_id": qt_id, "iid": item_id, "spid": supplier_id,
|
|
"number": number, "round": round_, "type": QuotationType.REQUOTE.value,
|
|
"sstatus": SessionStatus.DONE.value, "past": PAST, "custom": json.dumps(custom),
|
|
},
|
|
)
|
|
return {
|
|
"session_id": str(session_id), "quotation_id": str(qt_id),
|
|
"supplier_id": str(supplier_id), "user_id": str(user_id),
|
|
}
|
|
|
|
|
|
async def _renego_of(engine, session_id):
|
|
"""세션 custom.renegotiation 을 읽어 dict 로 (저장 결과 확인용)."""
|
|
async with engine.begin() as conn:
|
|
row = (await conn.execute(
|
|
text("SELECT custom FROM sessions WHERE session_id = :sid"),
|
|
{"sid": uuid.UUID(session_id)},
|
|
)).one()
|
|
return (row[0] or {}).get("renegotiation") or {}
|