직접 낙찰(담당자 오프라인 계약가)을 AI 자동 낙찰과 통계·화면에서 구분하기 위해
낙찰 방식을 전용 컬럼으로 박고, 계약가·사유를 성격대로 배치했다. 처음엔 계약가·사유를
sessions.custom.offline_award(JSONB) 한 뭉치에 넣었으나 성격이 갈려 정리한다.
DB (postgres-init: init.sql 정본 + alters/2026-08-11-award-type.sql 보정, 멱등)
- quotations.award_type SMALLINT — 낙찰 방식(1=자동/2=직접). 통계 조회·집계 축이라 컬럼
- quotations.custom JSONB — 직접 낙찰 사유·처리자·시각(award={reason,by,at}). 표시·감사용
- sessions.contract_price BIGINT — 직접 낙찰 계약가. bid_price/reject_price 와 같은 협력사
가격 축이라 세션에. 자동 낙찰은 NULL(투찰가가 곧 계약가)
- 기존 offline_award(JSONB) 데이터를 컬럼·견적 custom 으로 이관 후 키 제거
backend
- 자동 낙찰(close_and_decide)=AUTO, 직접 낙찰(claim_for_award)=MANUAL 로 award_type 기록
- 직접 낙찰: 계약가→세션 contract_price, 사유·처리자·시각→견적 custom.award (한 트랜잭션)
- 통계 계약가 = coalesce(contract_price, bid_price) — JSONB 캐스팅 제거(컬럼끼리, 인덱스·타입 안전)
- QuotationData 에 award_type·custom, SessionData 에 contract_price 노출
- 안 쓰게 된 merge_session_custom 제거
front
- 견적 상세 결과 밴드에 '직접 낙찰' 배지 + 낙찰 사유 표시(견적 custom.award.reason).
거부·미참여 낙찰은 이미 직접 낙찰을 함의하므로 '— 낙찰' 꼬리를 떼 중복 표기 제거
- 목록 결과 배지에 '낙찰(직접)' 표기 — AI 자동낙찰과 한눈에 구분
- offlineAward()→directAwardPrice()/awardMeta() 로 교체(세션 컬럼·견적 custom 에서 읽음)
- 협상현황 표: 부가정보(회사 필드)와 의견(custom.opinion)을 별도 컬럼으로 분리
- 미응찰 건 레일 4번 칸 라벨 '최저 투찰가'→'결과'(투찰 없을 때)
테스트: negodata 110건 통과. 프론트 tsc+eslint 통과. dev DB 적용·화면 확인.
255 lines
15 KiB
Python
255 lines
15 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 AwardType, CloseOutcome, CloseReason, DBWRType, ErrorType, NotificationType, PriceGateAction, QuotationStatus, SessionStatus
|
|
from common.logger import LOG
|
|
from common.utils.gtime import GTime
|
|
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,
|
|
"award_type": AwardType.AUTO.value, # 시스템 자동 낙찰(투찰가 기준)
|
|
})
|
|
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,
|
|
contract_price: int, contract_note: Optional[str] = None,
|
|
) -> Res_Quotation:
|
|
"""[프론트] 개찰(낙찰자 미정 마감) 견적을 담당자가 직접 낙찰 처리한다.
|
|
협상이 결렬·미응찰로 끝나면 오프라인으로 다시 협상하고 그 결과를 여기서 반영한다 — 그래서
|
|
후보는 견적에 초청된 협력사 전부이고, 계약가는 담당자가 확정해 넣는다(시스템 제출가와 다를 수 있다).
|
|
계약가는 sessions.custom.offline_award 에 근거 메모·작성자·시각과 함께 남겨 통계가 이 값을 계약가로 읽는다.
|
|
자동 낙찰(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))
|
|
|
|
if not contract_price or contract_price <= 0:
|
|
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
|
|
res.msg = "계약가를 입력해야 낙찰할 수 있습니다."
|
|
return res
|
|
|
|
# 존재 확인(+회사 가드) — 남의 회사 견적은 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), None)
|
|
if winner is None:
|
|
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
|
|
res.msg = "선택한 협력사는 이 견적에 초청된 협력사가 아닙니다."
|
|
return res
|
|
winner_price = int(contract_price)
|
|
|
|
# [동시 직접낙찰 가드] 개찰→낙찰 원자 선점. 실제로 전이한 호출자만 통과(재클릭·경합 방어).
|
|
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
|
|
|
|
# 계약가는 낙찰 세션의 contract_price 컬럼에(협력사 가격 — bid_price/reject_price 와 같은 축, 통계 집계 대상).
|
|
# 사유·처리자·시각은 견적의 custom.award 에(견적 단위 결정 — 표시·감사용, 집계 안 함). 둘 다 한 트랜잭션.
|
|
award_meta = {
|
|
"reason": (contract_note or "").strip()[:255],
|
|
"by": str(user_id) if user_id else "",
|
|
"at": GTime.UTC().isoformat(timespec="seconds"),
|
|
}
|
|
award_err = await DB_SESSION_MNG.execute_lambda_run(
|
|
[sessions.DBType()],
|
|
[
|
|
lambda s: self.quotation_crud.set_contract_price(s, winner.session_id, winner_price),
|
|
lambda s: self.quotation_crud.merge_quotation_custom(s, qt_uuid, {"award": award_meta}),
|
|
],
|
|
)
|
|
if award_err != ErrorType.SUCCESS:
|
|
# 낙찰(견적)은 이미 선점 전이됐다 — 계약가·사유만 못 남긴 상태라 되돌리지 않고 경고로 남긴다.
|
|
LOG.w(f"[award] 계약가/사유 기록 실패 qt_id={qt_id} session_id={winner.session_id} price={winner_price}")
|
|
|
|
# 작성자 알림 — 자동낙찰과 같은 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)
|