o2o-negosium-original/negodata/backend/scheduler/jobs.py
Mina Choi 5406094ea1 [wip] negodata/backend: 견적 마감 스케줄러
- scheduler/(__init__·jobs): 마감 처리·낙찰자 선정 잡
- quotation_crud·service: 스케줄러 연동 조회/마감 로직
- web_main·requirements·docker-compose·config: 스케줄러 기동 설정

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 17:38:02 +09:00

125 lines
6.0 KiB
Python

from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import quotations, sessions
from common.enums import DBWRType, ErrorType, QuotationStatus, SessionStatus
from common.logger import LOG
from common.utils.gtime import GTime
from crud.quotation_crud import QuotationCRUD
async def close_expired_quotations() -> int:
"""[잡①] 마감일이 지난 견적을 자동으로 견적마감 처리한다. 하루 한 번 실행.
대상: 마감 시각이 이미 지났는데 아직 마감되지 않은(삭제되지도 않은) 견적.
처리: 그 견적들을 견적마감 상태로 바꾸고, 아직 시작 전인 세션은 미참여로 정리한다.
반환: 마감 처리한 견적 수."""
crud = QuotationCRUD()
now = GTime.UTC()
err_type, qt_ids = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(),
DBWRType.DB_READ.value,
lambda s: crud.list_due_for_close(s, now),
)
if err_type != ErrorType.SUCCESS:
LOG.e_no_callstack(f"[scheduler] close_expired 대상 조회 실패: {err_type.name}")
return 0
if not qt_ids:
return 0
err_type = await DB_SESSION_MNG.execute_lambda_run(
[quotations.DBType()],
[
lambda s: crud.bulk_update_quotation_status(s, qt_ids, QuotationStatus.CLOSED.value),
lambda s: crud.bulk_update_sessions_status(
s, qt_ids, [SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value], SessionStatus.NOT_PARTICIPATED.value
),
],
)
if err_type != ErrorType.SUCCESS:
LOG.e_no_callstack(f"[scheduler] close_expired 마감 실패: {err_type.name}")
return 0
LOG.i(f"[scheduler] close_expired: {len(qt_ids)}건 견적마감")
return len(qt_ids)
async def complete_requote_quotations() -> int:
"""[잡②] 재견적은 협상완료된 세션이 생기면 나머지를 기다리지 않고 바로 마감한다. 한 시간마다 실행.
대상: 아직 마감되지 않은 재견적 견적 중, 협상완료된 세션이 있는 것.
낙찰: 협상완료된 세션 중 입찰가가 가장 낮은 공급사를 낙찰자로 정한다. 같은 최저가가 둘 이상이면(동가) 낙찰자를 비우고 동가 정보만 남긴다.
(현재 재견적은 견적당 세션이 하나라 실제로는 단독 낙찰만 일어나지만, 모델상 1:N이라 일반 규칙을 그대로 둔다.)
처리: 낙찰 정보를 기록하고 견적을 견적마감 상태로 바꾸며, 아직 시작 전인 세션은 미참여로 정리한다.
반환: 마감 처리한 견적 수."""
crud = QuotationCRUD()
err_type, qt_ids = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(),
DBWRType.DB_READ.value,
lambda s: crud.list_requote_done(s),
)
if err_type != ErrorType.SUCCESS:
LOG.e_no_callstack(f"[scheduler] complete_requote 대상 조회 실패: {err_type.name}")
return 0
if not qt_ids:
return 0
closed = 0
for qt_id in qt_ids:
e2, done_rows = await DB_SESSION_MNG.execute_lambda(
sessions.DBType(),
DBWRType.DB_READ.value,
lambda s, q=qt_id: crud.list_done_sessions(s, q),
)
if e2 != ErrorType.SUCCESS:
LOG.e_no_callstack(f"[scheduler] complete_requote DONE세션 조회 실패 qt_id={qt_id}: {e2.name}")
continue
# 현재 재견적은 세션이 하나라 사실상 단독 낙찰만 타지만, 모델상 1:N이라 일반 규칙(_pick_winner)을 그대로 쓴다.
winner, equal = _pick_winner(done_rows)
# 단독 낙찰과 동가는 상호배타(KTC 정본). 플래그를 명시적으로 박는다.
data = {
"status": QuotationStatus.CLOSED.value,
"preferred_sp_yn": winner is not None,
"equal_bid_yn": equal is not None,
}
if winner is not None:
data["preferred_sp_id"] = winner["supplier_id"]
data["preferred_sp_name"] = (winner["name"] or "")[:20]
if equal is not None:
data["equal_bid_data"] = equal
e3 = await DB_SESSION_MNG.execute_lambda_run(
[quotations.DBType()],
[
lambda s, d=data, q=qt_id: crud.update_quotation(s, q, d),
lambda s, q=qt_id: crud.update_sessions_status(
s, q, [SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value], SessionStatus.NOT_PARTICIPATED.value
),
],
)
if e3 == ErrorType.SUCCESS:
closed += 1
else:
LOG.e_no_callstack(f"[scheduler] complete_requote 마감 실패 qt_id={qt_id}: {e3.name}")
if closed:
LOG.i(f"[scheduler] complete_requote: {closed}건 견적마감")
return closed
def _pick_winner(done_rows):
"""협상완료된 세션들 중에서 낙찰자를 정한다(KTC 정본 규칙). complete_requote_quotations 전용 헬퍼.
입찰가가 매겨진 세션들 가운데 가장 낮은 가격을 부른 공급사를 낙찰자로 본다.
- 최저가를 부른 곳이 한 곳뿐이면: 그 공급사를 낙찰자로 정하고, 동가는 없다.
- 최저가가 둘 이상으로 같으면(동가): 낙찰자는 비우고 동가 정보(최저가와 그 공급사들)만 남긴다.
- 입찰가가 매겨진 세션이 하나도 없으면: 낙찰자도 동가 정보도 없다.
낙찰자와 동가 정보를 한 쌍으로 돌려주며, 둘은 동시에 채워지지 않는다(단독 낙찰 또는 동가, 둘 중 하나)."""
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]}, None