o2o-negosium-original/negodata/backend/tests/test_quotation_regenerate.py

212 lines
9.7 KiB
Python

"""견적 재생성 조정값 — 담당자가 다음 라운드에서만 바꾼 값(카드·목표가·마감기한·타결 상한율)이 반영되는지 검증.
기본 계약은 '미전송 = 원 견적/직전 라운드 승계'다(기존 동작). 보내면 그 값으로 라운드가 만들어진다.
· card_ids — None=원본 카드 버전 재사용 / 리스트=그 카드들로 새 버전 / []=카드 없는 버전
· target_price — 이번 라운드 전 상품의 목표가(세션 target_price + quotations.md_price)
· end_time — 마감기한(견적·세션 공통). 미전송이면 원 견적과 같은 협상기간
· done_ceiling_rate — 타결 상한율(‰) → 세션 done_ceiling_price 로 박제
앵커링가는 어느 경우든 재계산이라 여기선 보지 않는다(test_quotation_anchoring 소관).
"""
import uuid
from datetime import datetime, timezone
from sqlalchemy import text
from common.enums import QuotationStatus, QuotationType
from crud.quotation_crud import QuotationCRUD
from router.v1.quotation.protocol import Req_CreateQuotation
from services.quotation import QuotationService
FUTURE = datetime(2999, 1, 1) # 마감시각 미래 — 생성 직후 크론에 안 잡히게
NEXT_DUE = "2999-06-01T00:00:00Z" # 재생성 때 다시 잡는 마감기한(프론트가 보내는 형태 = UTC ISO)
TARGET = 100_000 # 1라운드 목표가(= MD 제시가 그대로)
async def test_regenerate_without_overrides_inherits_everything(db_engine, client, auth_headers):
"""검증: 조정값 없이 공급사만 보내 재생성.
기대결과: 목표가·카드 버전·타결 상한율이 원 견적 그대로 승계되고 차수만 +1."""
ctx = await _closed_round1(db_engine, client, auth_headers, "regen_plain", ceiling_rate=50)
body = await _regenerate(client, ctx, {"supplier_ids": [str(ctx["supplier"])]})
assert body["result"]["success"] is True
q = await _quotation(db_engine, body["qt_id"])
assert (q["round"], q["md_price"], q["done_ceiling_rate"]) == (2, TARGET, 50)
assert q["version_id"] == ctx["version_id"] # 새 버전 안 만듦 — 원본 카드 버전 재사용
s = await _session(db_engine, body["qt_id"])
assert s["target_price"] == TARGET
assert s["done_ceiling_price"] == 105_000 # 목표가 +5%
async def test_regenerate_applies_target_price_and_ceiling(db_engine, client, auth_headers):
"""검증: 목표가 9만원 + 타결 상한율 100‰(=10%)로 재생성.
기대결과: 세션 목표가·견적 md_price 가 새 값, 타결 상한가는 새 목표가 기준으로 재계산(99,000)."""
ctx = await _closed_round1(db_engine, client, auth_headers, "regen_target", ceiling_rate=50)
body = await _regenerate(client, ctx, {
"supplier_ids": [str(ctx["supplier"])],
"target_price": 90_000,
"done_ceiling_rate": 100,
})
assert body["result"]["success"] is True
q = await _quotation(db_engine, body["qt_id"])
assert (q["md_price"], q["done_ceiling_rate"]) == (90_000, 100)
s = await _session(db_engine, body["qt_id"])
assert (s["target_price"], s["done_ceiling_price"]) == (90_000, 99_000)
async def test_regenerate_applies_end_time(db_engine, client, auth_headers):
"""검증: 마감기한(UTC ISO)을 직접 지정해 재생성(원 견적 협상기간 승계 대신).
기대결과: 견적·세션 end_time 이 보낸 시각 그대로. 미지정 경로(승계)와 달리 생성시각+기간이 아니다."""
ctx = await _closed_round1(db_engine, client, auth_headers, "regen_due", ceiling_rate=50)
body = await _regenerate(client, ctx, {"supplier_ids": [str(ctx["supplier"])], "end_time": NEXT_DUE})
assert body["result"]["success"] is True
q = await _quotation(db_engine, body["qt_id"])
s = await _session(db_engine, body["qt_id"])
due = datetime(2999, 6, 1, tzinfo=timezone.utc)
assert q["end_time"] == due
assert s["end_time"] == due
async def test_regenerate_replaces_cards_with_new_version(db_engine, client, auth_headers):
"""검증: 직전 라운드와 다른 카드 1장으로 재생성.
기대결과: 원본과 다른 새 버전이 생기고 그 버전엔 보낸 카드만 매핑된다(원본 버전은 그대로 남음)."""
ctx = await _closed_round1(db_engine, client, auth_headers, "regen_cards", ceiling_rate=50)
new_card = await _seed_nego_card(db_engine)
body = await _regenerate(client, ctx, {"supplier_ids": [str(ctx["supplier"])], "card_ids": [str(new_card)]})
assert body["result"]["success"] is True
q = await _quotation(db_engine, body["qt_id"])
assert q["version_id"] != ctx["version_id"]
assert await _version_cards(db_engine, q["version_id"]) == {new_card}
assert await _version_cards(db_engine, ctx["version_id"]) == {ctx["card_id"]} # 직전 라운드 카드 이력 보존
async def test_regenerate_with_empty_cards_makes_cardless_version(db_engine, client, auth_headers):
"""검증: 카드를 전부 해제(빈 리스트)한 채 재생성.
기대결과: 원본 버전을 그대로 물려받지 않고, 카드가 하나도 안 걸린 새 버전으로 생성된다."""
ctx = await _closed_round1(db_engine, client, auth_headers, "regen_nocard", ceiling_rate=50)
body = await _regenerate(client, ctx, {"supplier_ids": [str(ctx["supplier"])], "card_ids": []})
assert body["result"]["success"] is True
q = await _quotation(db_engine, body["qt_id"])
assert q["version_id"] != ctx["version_id"]
assert await _version_cards(db_engine, q["version_id"]) == set()
# ===== 헬퍼 =====
def _service():
return QuotationService(QuotationCRUD())
async def _closed_round1(engine, client, auth_headers, login_id, *, ceiling_rate):
"""재생성 대상(마감된 1라운드)을 만든다 — 카드 1장·공급사 1곳짜리 1:1 협상 견적.
생성은 서비스로(견적 생성 API 는 로그인 유저를 작성자로 박으므로 같은 유저로 맞춘다),
재생성은 HTTP 로 태워 라우터→서비스 인자 전달까지 함께 본다.
"""
headers = await auth_headers(login_id)
user_id = await _user_id(engine, login_id)
item_id = await _seed_item(engine, await _company_of(engine, user_id))
card_id = await _seed_nego_card(engine)
supplier = uuid.uuid4()
req = Req_CreateQuotation(
qt_setting_id=uuid.uuid4(), # FK 미설정 — 세팅 없으면 율 0(목표가는 md_price 로 확정)
name="재생성원본",
type=QuotationType.NEW_NEGO.value,
end_time=FUTURE,
md_price=TARGET,
item_ids=[item_id],
supplier_ids=[supplier],
card_ids=[card_id],
done_ceiling_rate=ceiling_rate,
)
res = await _service().create_quotation(str(user_id), req)
assert res.result.success is True
# 재생성은 마감 견적에서만 — 크론 마감을 기다리지 않고 상태만 CLOSED 로 돌린다.
async with engine.begin() as conn:
await conn.execute(
text("UPDATE quotations SET status = :st WHERE qt_id = :qt"),
{"st": QuotationStatus.CLOSED.value, "qt": res.qt_id},
)
original = await _quotation(engine, str(res.qt_id))
return {"qt_id": str(res.qt_id), "headers": headers, "supplier": supplier,
"card_id": card_id, "version_id": original["version_id"]}
async def _regenerate(client, ctx, payload):
r = await client.post(f"/v1/quotation/regenerate/{ctx['qt_id']}", json=payload, headers=ctx["headers"])
return r.json()
async def _user_id(engine, login_id):
async with engine.begin() as conn:
return (await conn.execute(
text("SELECT user_id FROM users WHERE id = :id"), {"id": login_id}
)).scalar_one()
async def _company_of(engine, user_id):
async with engine.begin() as conn:
return (await conn.execute(
text("SELECT company_id FROM users WHERE user_id = :uid"), {"uid": user_id}
)).scalar_one()
async def _seed_item(engine, company_id):
"""상품 1건 시드. NOT NULL 컬럼은 명시(ORM default 는 raw INSERT 에 안 먹음)."""
item_id = uuid.uuid4()
async with engine.begin() as conn:
await conn.execute(
text("INSERT INTO items (item_id, company_id, user_id, name, category_type, internet_lowest_price_yn) "
"VALUES (:item_id, :company_id, :user_id, '상품', 1, false)"),
{"item_id": item_id, "company_id": company_id, "user_id": uuid.uuid4()},
)
return item_id
async def _seed_nego_card(engine):
card_id = uuid.uuid4()
async with engine.begin() as conn:
await conn.execute(
text("INSERT INTO nego_cards (nego_card_id, user_id, name, number, script, usage_type) "
"VALUES (:cid, :uid, '카드', 'N1', '멘트', 1)"),
{"cid": card_id, "uid": uuid.uuid4()},
)
return card_id
async def _quotation(engine, qt_id):
async with engine.begin() as conn:
row = (await conn.execute(
text("SELECT round, version_id, md_price, done_ceiling_rate, end_time "
"FROM quotations WHERE qt_id = :qt"),
{"qt": uuid.UUID(qt_id)},
)).mappings().one()
return dict(row)
async def _session(engine, qt_id):
"""견적의 세션 1건(상품·공급사 1:1 시드라 단건)."""
async with engine.begin() as conn:
row = (await conn.execute(
text("SELECT target_price, done_ceiling_price, end_time FROM sessions WHERE quotation_id = :qt"),
{"qt": uuid.UUID(qt_id)},
)).mappings().one()
return dict(row)
async def _version_cards(engine, version_id):
async with engine.begin() as conn:
rows = (await conn.execute(
text("SELECT nego_card_id FROM version_nego_cards WHERE version_id = :vid"),
{"vid": version_id},
)).scalars().all()
return set(rows)