- scheduler/(__init__·jobs): 마감 처리·낙찰자 선정 잡 - quotation_crud·service: 스케줄러 연동 조회/마감 로직 - web_main·requirements·docker-compose·config: 스케줄러 기동 설정 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
125 lines
6.0 KiB
Python
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
|