"""마감 판정(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)