226 lines
13 KiB
Python
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)
|