o2o-negosium-original/negodata/backend/services/quotation/closing.py

226 lines
13 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
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:
"""[프론트] 개찰(낙찰자 미정 마감) 견적을 담당자가 직접 낙찰 처리한다.
투찰한 협상완료(DONE) 세션 중 고른 협력사를 낙찰자로 박고 close_reason 을 AWARDED 로 바꾼다(직접 낙찰).
자동 낙찰(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
# 낙찰 후보 = 투찰한 협상완료(DONE) 세션. 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.status == SessionStatus.DONE.value and r.bid_price is not None and r.supplier_id == sp_uuid),
None,
)
if winner is None:
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
res.msg = "선택한 협력사는 이 견적의 낙찰 후보(투찰한 협상완료 협력사)가 아닙니다."
return res
# [동시 직접낙찰 가드] 개찰→낙찰 원자 선점. 실제로 전이한 호출자만 통과(재클릭·경합 방어).
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.bid_price, "manual": True},
ref_qt_id=qt_uuid,
)
return await self.get_quotation(qt_id, company_id)