"""견적 재생성 조정값 — 담당자가 다음 라운드에서만 바꾼 값(카드·목표가·마감기한·타결 상한율)이 반영되는지 검증. 기본 계약은 '미전송 = 원 견적/직전 라운드 승계'다(기존 동작). 보내면 그 값으로 라운드가 만들어진다. · 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)