o2o-negosium-original/negodata/backend/services/quotation/closing.py
Mina Choi ac6366a898 [feat] negosium·negodata: 협상 진행 중 거부 + 결렬 건 최종 제출가로 직접 낙찰
협력사가 단종·품절을 대화 도중 알아채도 봇의 결렬 선언을 기다려야 했고,
거부로 끝난 협상은 가격을 남겨도 낙찰 후보에서 빠져 계약으로 이어지지 않았다.

negosium
- 채팅 액션바에 협상 거부 진입점 — 대화가 끝나지 않고 입력을 기다리는 동안만 노출,
  주 CTA 와 붙지 않게 넓은 화면은 우측 끝 고정·좁은 화면은 wrap
- 거부 팝업은 목록 거부 팝업과 같은 어휘·규격, 대화 중이라 공급 희망 가격·의견을 더 받는다
- /reject 에 reject_price·opinion 추가 — sessions.reject_price 저장, 의견은 custom 병합
- 화면 문구 '거절' → '거부' 통일 (버튼·배지·탭·토스트·안내 팝업)

negodata
- 개찰 견적 직접 낙찰 후보 = 가격을 써낸 세션 — 투찰한 협상완료 + 공급 희망가를 남긴 협상거부
- 계약가 파생 _award_price/awardPrice — coalesce(투찰가, 거부 시 공급 희망가)
- 통계 낙찰 세션 조인도 같은 기준 — 안 고치면 거부가로 낙찰한 건이 절감 집계에서 빠진다
- 세션 상태 탭 라벨 '거절사유/거절가격/거절배송방식' → '거부…'

자동 마감 판정(close_and_decide)은 그대로 — 자동 낙찰은 투찰가만 본다.
2026-08-11 10:34:07 +09:00

236 lines
14 KiB
Python

"""마감 판정(close_and_decide)·수동 마감·직접 낙찰."""
import uuid
from typing import Optional
from common.authz import is_owner_or_admin
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import quotations, sessions
from common.enums import CloseOutcome, CloseReason, DBWRType, ErrorType, NotificationType, PriceGateAction, QuotationStatus, SessionStatus
from router.v1.quotation.protocol import Res_Quotation
from services.notification import create_notification
def _award_price(row) -> Optional[int]:
"""직접 낙찰 시 계약 기준가 — 타결했으면 투찰가, 협상이 거부로 끝났으면 그때 써낸 공급 희망 가격.
둘 다 없으면(가격을 한 번도 안 낸 협력사) None → 낙찰 후보가 아니다.
자동 마감 판정(close_and_decide)은 이 함수를 쓰지 않는다 — 자동 낙찰은 투찰가만 본다."""
if row.status == SessionStatus.DONE.value and row.bid_price is not None:
return int(row.bid_price)
if row.status == SessionStatus.REJECTED.value and row.reject_price is not None:
return int(row.reject_price)
return None
class ClosingMixin:
@staticmethod
def _pick_winner(done_rows) -> tuple[Optional[dict], Optional[dict]]:
"""협상완료 세션들 중 낙찰자 판정. done_rows: [(supplier_id, bid_price, supplier_name), ...].
입찰가가 매겨진 세션 중 최저가가 단독이면 그 공급사를 낙찰로, 동가(둘+)면 낙찰은 비우고 동가 정보만 남긴다.
단독/동가는 상호배타. 반환: (winner|None, equal|None)."""
cands = [(sid, int(bp), name) for sid, bp, name in done_rows if bp is not None]
if not cands:
return None, None
min_price = min(c[1] for c in cands)
tied = [c for c in cands if c[1] == min_price]
if len(tied) > 1:
equal = {"price": min_price, "suppliers": [{"supplier_id": str(sid), "name": name} for sid, _, name in tied]}
return None, equal
return {"supplier_id": tied[0][0], "name": tied[0][2], "bid_price": min_price}, None
@staticmethod
def _gate_action(bid, target, anchor, mid_action, over_action) -> int:
"""낙찰 기준 판정 → PriceGateAction 코드(AWARD=낙찰 / OPEN=개찰).
bid ≤ 앵커링가 → 무조건 낙찰(AWARD)
앵커링가 < bid ≤ 목표가 → mid_action(견적 낙찰 기준)
목표가 < bid → over_action(1:1 협상은 항상 OPEN=개찰)
target/bid 없으면(산정 불가 등) AWARD 폴백(최저가 그대로 낙찰)."""
if bid is None or target is None:
return PriceGateAction.AWARD.value
bid = int(bid)
if anchor is not None and bid <= int(anchor):
return PriceGateAction.AWARD.value
if bid <= int(target):
return mid_action or PriceGateAction.AWARD.value
return over_action or PriceGateAction.AWARD.value
async def _close(self, qt_uuid, close_reason: int, data: Optional[dict] = None) -> None:
"""마감 공통: status→CLOSED + close_reason 기록 + (있으면)추가데이터 + 미완료(미시작·진행중) 세션→미참여.
close_reason(CloseReason)이 낙찰/개찰 사유 구분의 단일 근거.
preferred_sp_*/equal_bid_* 는 프론트 표시용으로 함께 채운다(사유 판별은 close_reason 이 담당)."""
payload = {"status": QuotationStatus.CLOSED.value, "close_reason": close_reason}
if data:
payload.update(data)
await DB_SESSION_MNG.execute_lambda_run(
[quotations.DBType()],
[
lambda s: self.quotation_crud.update_quotation(s, qt_uuid, payload),
lambda s: self.quotation_crud.update_sessions_status(
s, qt_uuid, [SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value], SessionStatus.NOT_PARTICIPATED.value
),
],
)
async def _open(self, qt_uuid, original, close_reason: int, reason: str, data: Optional[dict] = None) -> CloseOutcome:
"""개찰 마감 — 낙찰자 미정으로 CLOSED + close_reason(OPEN_*) 기록 + 작성자 알림. 자동 재협상/재생성 없음(담당자 수동 처리).
결렬(유찰) 아님. 알림 코드는 유지하되 프론트에서 '개찰'로 표기한다."""
await self._close(qt_uuid, close_reason, data)
await create_notification(
original.user_id, NotificationType.FAILURE,
{"qt_name": original.name, "qt_number": original.number, "reason": reason},
ref_qt_id=qt_uuid,
)
return CloseOutcome.OPENED
async def close_and_decide(self, qt_id) -> CloseOutcome:
"""[마감] 견적을 마감하며 결과 판정. 협상완료 단독 최저가가 낙찰 기준을 통과할 때만 낙찰(AWARDED).
- 단독 최저가: ≤앵커 항상 낙찰 / 앵커~목표 mid_action / 목표초과 over_action(1:1 협상은 항상 OPEN=개찰).
- 그 외(기준 미달·동가·협상거부·전원 미응찰)는 결렬(유찰)이 아니라 개찰(OPEN_*) — 낙찰자 미정으로 마감.
자동 재협상/재생성 없음. 다음 라운드는 담당자가 상세에서 수동 재생성(regenerate_quotation)한다.
공통: 원자적 status→CLOSED 선점, 미시작·진행중 세션→미참여."""
qt_uuid = qt_id if isinstance(qt_id, uuid.UUID) else uuid.UUID(str(qt_id))
err_type, original = await self._fetch(qt_uuid)
if err_type != ErrorType.SUCCESS or original is None:
return CloseOutcome.CLOSED
# [동시 마감 가드] 원자적으로 status→CLOSED 선점. 실제로 전이한 호출자만 통과.
claim_err, claimed = await DB_SESSION_MNG.execute_lambda_claim(
quotations.DBType(),
lambda s: self.quotation_crud.claim_for_close(s, qt_uuid),
)
if claim_err != ErrorType.SUCCESS or claimed == 0:
return CloseOutcome.CLOSED
err_type, rows = await DB_SESSION_MNG.execute_lambda(
sessions.DBType(),
DBWRType.DB_READ.value,
lambda s: self.quotation_crud.list_sessions_status(s, qt_uuid),
)
rows = rows if err_type == ErrorType.SUCCESS else []
done = [(r.supplier_id, r.bid_price, r.name) for r in rows if r.status == SessionStatus.DONE.value]
has_rejected = any(r.status == SessionStatus.REJECTED.value for r in rows)
winner, equal = self._pick_winner(done)
# 낙찰 기준 입력: 견적 단위(mid/over, 생성 시점 박제) + 세션 목표가/앵커링가(견적당 상품 1개라 세션 공통값)
mid_action = original.mid_action or PriceGateAction.AWARD.value
over_action = original.over_action or PriceGateAction.AWARD.value
target = next((r.target_price for r in rows if r.target_price is not None), None)
anchor = next((r.anchoring_price for r in rows if r.anchoring_price is not None), None)
# 1) 단독 최저가가 낙찰 기준 통과 → 낙찰. 미달 → 개찰(가격).
if winner is not None:
action = self._gate_action(winner["bid_price"], target, anchor, mid_action, over_action)
if action == PriceGateAction.AWARD.value:
await self._close(qt_uuid, CloseReason.AWARDED.value, {
"preferred_sp_yn": True, "preferred_sp_id": winner["supplier_id"],
"preferred_sp_name": (winner["name"] or "")[:20], "equal_bid_yn": False,
})
await create_notification(
original.user_id, NotificationType.SUCCESS,
{"qt_name": original.name, "qt_number": original.number,
"winner_name": winner["name"], "winner_price": winner["bid_price"]},
ref_qt_id=qt_uuid,
)
return CloseOutcome.AWARDED
# 개찰(가격) — 낙찰/동가 플래그는 NULL 로 둔다(대시보드 '개찰' 스코프가 preferred/equal 둘 다 NULL 로 집계).
return await self._open(qt_uuid, original, CloseReason.OPEN_PRICE.value, "price")
# 2) 동가(최저가 동점) → 개찰(동가). 낙찰자 미정. equal_bid_yn 으로 표기(대시보드 '동가' 스코프).
if equal is not None:
return await self._open(qt_uuid, original, CloseReason.OPEN_EQUAL.value, "equal",
{"equal_bid_yn": True, "equal_bid_data": equal})
# 3) 협상거부 있음 → 개찰(거부).
if has_rejected:
return await self._open(qt_uuid, original, CloseReason.OPEN_REJECT.value, "rejected")
# 4) 전원 미응찰 → 개찰(미응찰).
return await self._open(qt_uuid, original, CloseReason.OPEN_NOSHOW.value, "no_show")
async def stop_quotation(self, qt_id: str, company_id=None, user_id=None, role=None) -> Res_Quotation:
"""[프론트] 수동 견적마감. 크론과 똑같은 마감 판정(close_and_decide)을 탄다
(낙찰 확정 / 그 외 전부 개찰 — 낙찰자 미정 마감. 재생성은 별도 수동 API)."""
res = Res_Quotation()
qt_uuid = uuid.UUID(qt_id)
# 존재 확인(+회사 가드)
err_type, original = await self._fetch(qt_uuid, company_id)
if err_type != ErrorType.SUCCESS or original is None:
res.result.SetResult(err_type)
return res
# 소유자 게이팅 — 본인 견적 또는 최고관리자만 마감(user_id 미지정=내부 호출은 스킵).
if user_id is not None and not is_owner_or_admin(original.user_id, user_id, role):
res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN)
res.msg = "본인이 생성한 견적만 마감할 수 있습니다."
return res
await self.close_and_decide(qt_uuid)
return await self.get_quotation(qt_id, company_id)
async def award_quotation(self, qt_id: str, company_id, user_id, role, winner_supplier_id) -> Res_Quotation:
"""[프론트] 개찰(낙찰자 미정 마감) 견적을 담당자가 직접 낙찰 처리한다.
가격을 써낸 세션 중 고른 협력사를 낙찰자로 박고 close_reason 을 AWARDED 로 바꾼다(직접 낙찰).
후보는 투찰한 협상완료(DONE) + 공급 희망 가격을 남긴 협상거부(REJECTED) — 협상이 결렬돼도
마지막에 제출한 가격으로 계약을 진행하는 운영 방침을 시스템에서 그대로 처리하기 위함이다.
자동 낙찰(close_and_decide)과 결과 컬럼은 같되, 알림에 manual 플래그로 '직접 낙찰'임을 남긴다.
권한: 본인이 생성한 견적만. 단 최고관리자(OWNER)는 회사 내 남의 견적도 낙찰할 수 있다."""
res = Res_Quotation()
qt_uuid = uuid.UUID(qt_id)
sp_uuid = winner_supplier_id if isinstance(winner_supplier_id, uuid.UUID) else uuid.UUID(str(winner_supplier_id))
# 존재 확인(+회사 가드) — 남의 회사 견적은 NOT_FOUND.
err_type, original = await self._fetch(qt_uuid, company_id)
if err_type != ErrorType.SUCCESS or original is None:
res.result.SetResult(err_type if err_type != ErrorType.SUCCESS else ErrorType.QUOTATION_NOT_FOUND)
return res
# 자기 견적만 직접 낙찰 — 최고관리자(OWNER)만 회사 내 남의 견적도 허용. 되돌릴 수 없는 낙찰이라 백엔드에서 강제한다.
if not is_owner_or_admin(original.user_id, user_id, role):
res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN)
res.msg = "본인이 생성한 견적만 낙찰할 수 있습니다."
return res
# 개찰(마감·낙찰자 미정, close_reason ∈ OPEN_*)만 직접 낙찰 대상. 진행중/이미 낙찰은 거부.
if original.status != QuotationStatus.CLOSED.value or original.close_reason not in (
CloseReason.OPEN_PRICE.value, CloseReason.OPEN_EQUAL.value,
CloseReason.OPEN_NOSHOW.value, CloseReason.OPEN_REJECT.value,
):
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
res.msg = "개찰(낙찰자 미정) 상태의 견적만 직접 낙찰할 수 있습니다."
return res
# 낙찰 후보 = 가격을 써낸 세션. close_and_decide 와 같은 조회(list_sessions_status,
# 공급사 삭제돼도 포함되는 outerjoin)를 쓴다.
err_type, rows = await DB_SESSION_MNG.execute_lambda(
sessions.DBType(),
DBWRType.DB_READ.value,
lambda s: self.quotation_crud.list_sessions_status(s, qt_uuid),
)
rows = rows if err_type == ErrorType.SUCCESS else []
winner = next((r for r in rows if r.supplier_id == sp_uuid and _award_price(r) is not None), None)
if winner is None:
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
res.msg = "선택한 협력사는 이 견적의 낙찰 후보(가격을 제출한 협력사)가 아닙니다."
return res
winner_price = _award_price(winner)
# [동시 직접낙찰 가드] 개찰→낙찰 원자 선점. 실제로 전이한 호출자만 통과(재클릭·경합 방어).
claim_err, claimed = await DB_SESSION_MNG.execute_lambda_claim(
quotations.DBType(),
lambda s: self.quotation_crud.claim_for_award(s, qt_uuid, sp_uuid, (winner.name or "")[:20]),
)
if claim_err != ErrorType.SUCCESS or claimed == 0:
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
res.msg = "이미 낙찰 처리된 견적입니다."
return res
# 작성자 알림 — 자동낙찰과 같은 SUCCESS 코드, manual 플래그로 '직접 낙찰' 구분.
await create_notification(
original.user_id, NotificationType.SUCCESS,
{"qt_name": original.name, "qt_number": original.number,
"winner_name": winner.name, "winner_price": winner_price, "manual": True},
ref_qt_id=qt_uuid,
)
return await self.get_quotation(qt_id, company_id)