- scheduler/(__init__·jobs): 마감 처리·낙찰자 선정 잡 - quotation_crud·service: 스케줄러 연동 조회/마감 로직 - web_main·requirements·docker-compose·config: 스케줄러 기동 설정 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
455 lines
18 KiB
Python
455 lines
18 KiB
Python
import uuid
|
||
from datetime import timezone
|
||
|
||
from fastapi import Depends
|
||
|
||
from common.database.db_session_manager import DB_SESSION_MNG
|
||
from common.database.model.models import quotations, sessions, chats, versions, version_nego_cards, version_wild_cards
|
||
from common.enums import DBWRType, ErrorType, QuotationStatus, SessionStatus
|
||
from common.models.gmodel import PageParams
|
||
from common.utils.gtime import GTime
|
||
from config.server_configs import web_server_config
|
||
from crud.quotation_crud import IQuotationCRUD, QuotationCRUD
|
||
from router.v1.quotation.protocol import (
|
||
AsyncJob,
|
||
ChatMessageData,
|
||
QuotationCardData,
|
||
QuotationData,
|
||
SessionData,
|
||
Res_CreateQuotation,
|
||
Res_DeleteQuotation,
|
||
Res_Quotation,
|
||
Res_QuotationCards,
|
||
Res_QuotationList,
|
||
Res_QuotationResult,
|
||
Res_QuotationSessions,
|
||
Res_QuotationStatus,
|
||
Res_SessionChat,
|
||
)
|
||
|
||
|
||
class QuotationService:
|
||
"""견적 비즈니스 로직.
|
||
|
||
quotations 테이블에는 company_id 가 없어 회사 스코핑은 하지 않는다(토큰 검증만).
|
||
user_id 는 생성 시 소유자로만 기록한다(조회/변경 시 소유권 필터 없음).
|
||
"""
|
||
|
||
# 기본 전략 버전(card.versions 시드). 견적 생성 시 version_id 미지정이면 이 값으로 채운다.
|
||
DEFAULT_VERSION_ID = uuid.UUID("00000000-0000-0000-0000-000000000030")
|
||
|
||
def __init__(self, quotation_crud: IQuotationCRUD = Depends(QuotationCRUD)):
|
||
self.quotation_crud = quotation_crud
|
||
|
||
@staticmethod
|
||
def _session_chat_url(session_id) -> str:
|
||
"""세션 chat 실행 URL(공급사 협상 프론트). ChatPage 가 session_id 쿼리로 진입한다."""
|
||
base = (web_server_config.nego_chat_url or "").rstrip("/")
|
||
return f"{base}/chat?session_id={session_id}"
|
||
|
||
@staticmethod
|
||
def _calc_target_price(price, margin) -> int:
|
||
"""세션 목표가(원). 단가 있으면 목표 마진율 적용가, 없으면 0."""
|
||
if not price:
|
||
return 0
|
||
if margin and margin > 0:
|
||
return int(int(price) / (1 + margin))
|
||
return int(price)
|
||
|
||
@staticmethod
|
||
def _naive_utc(dt):
|
||
"""DB 컬럼이 naive(TIMESTAMP WITHOUT TIME ZONE)라, tz-aware 입력(프론트 toISOString 등)은 UTC naive 로 변환."""
|
||
if dt is None:
|
||
return dt
|
||
if getattr(dt, "tzinfo", None) is not None:
|
||
return dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||
return dt
|
||
|
||
@staticmethod
|
||
def _gen_number() -> str:
|
||
"""견적번호 자동 생성(미지정 시). EST-YYYYMM-XXXX."""
|
||
now = GTime.UTC()
|
||
return f"EST-{now:%Y%m}-{uuid.uuid4().hex[:4].upper()}"
|
||
|
||
async def _fetch(self, qt_id: uuid.UUID):
|
||
"""견적 단건 조회. (ErrorType, quotation|None) 반환. (회사 스코프 없음)"""
|
||
err_type, quotation = await DB_SESSION_MNG.execute_lambda(
|
||
quotations.DBType(),
|
||
DBWRType.DB_READ.value,
|
||
lambda s: self.quotation_crud.get_by_id(s, qt_id),
|
||
)
|
||
if err_type != ErrorType.SUCCESS or quotation is None:
|
||
return ErrorType.QUOTATION_NOT_FOUND, None
|
||
return ErrorType.SUCCESS, quotation
|
||
|
||
async def list_quotations(self, search, status, type_, start_from, start_to, pg: PageParams) -> Res_QuotationList:
|
||
res = Res_QuotationList(page=pg.page, size=pg.size)
|
||
|
||
err_type, rows, total = await DB_SESSION_MNG.execute_lambda(
|
||
quotations.DBType(),
|
||
DBWRType.DB_READ.value,
|
||
lambda s: self.quotation_crud.search(s, search, status, type_, start_from, start_to, pg.skip, pg.size),
|
||
)
|
||
if err_type != ErrorType.SUCCESS:
|
||
res.result.SetResult(err_type)
|
||
return res
|
||
|
||
# 참여 협력사 수(세션 distinct supplier)와 대표 상품(세션 item)을 이 페이지 견적들에 대해
|
||
# 각각 한 방으로 모아 합친다(메인 쿼리 비건드림).
|
||
qt_ids = [r.qt_id for r in rows]
|
||
counts = {}
|
||
item_map = {}
|
||
if qt_ids:
|
||
cnt_err, got = await DB_SESSION_MNG.execute_lambda(
|
||
quotations.DBType(),
|
||
DBWRType.DB_READ.value,
|
||
lambda s: self.quotation_crud.session_counts(s, qt_ids),
|
||
)
|
||
if cnt_err == ErrorType.SUCCESS:
|
||
counts = got
|
||
im_err, got_im = await DB_SESSION_MNG.execute_lambda(
|
||
quotations.DBType(),
|
||
DBWRType.DB_READ.value,
|
||
lambda s: self.quotation_crud.item_map(s, qt_ids),
|
||
)
|
||
if im_err == ErrorType.SUCCESS:
|
||
item_map = got_im
|
||
for r in rows:
|
||
r.participation_count = counts.get(r.qt_id, 0)
|
||
item = item_map.get(r.qt_id)
|
||
if item:
|
||
r.item_id, r.item_name = item
|
||
|
||
res.quotations = [QuotationData.model_validate(r) for r in rows]
|
||
res.total = total
|
||
return res
|
||
|
||
async def get_quotation(self, qt_id: str) -> Res_Quotation:
|
||
res = Res_Quotation()
|
||
err_type, quotation = await self._fetch(uuid.UUID(qt_id))
|
||
if err_type != ErrorType.SUCCESS:
|
||
res.result.SetResult(err_type)
|
||
return res
|
||
res.quotation = QuotationData.model_validate(quotation)
|
||
return res
|
||
|
||
async def create_quotation(self, user_id: str, data: dict) -> Res_CreateQuotation:
|
||
res = Res_CreateQuotation()
|
||
|
||
# 세션 생성용 입력은 quotations 컬럼이 아니므로 분리한다(상품 × 공급사 조합마다 세션 1개).
|
||
item_ids = data.pop("item_ids", []) or []
|
||
supplier_ids = data.pop("supplier_ids", []) or []
|
||
card_ids = data.pop("card_ids", []) or []
|
||
|
||
# NOT NULL 컬럼 보정(프론트 미전송 시 서버 디폴트).
|
||
if not data.get("version_id"):
|
||
data["version_id"] = self.DEFAULT_VERSION_ID
|
||
if not data.get("start_time"):
|
||
data["start_time"] = GTime.UTC()
|
||
if not data.get("number"):
|
||
data["number"] = self._gen_number()
|
||
if not data.get("status"):
|
||
data["status"] = QuotationStatus.CREATED.value
|
||
if not data.get("round"):
|
||
data["round"] = 1 # ORM 기본값은 flush 시점이라, 세션 스냅샷용으로 미리 확정한다
|
||
# DB 컬럼이 naive 라, tz-aware 로 들어온 시각(프론트 toISOString)을 UTC naive 로 맞춘다.
|
||
data["start_time"] = self._naive_utc(data.get("start_time"))
|
||
data["end_time"] = self._naive_utc(data.get("end_time"))
|
||
|
||
# 세션 목표가 입력(상품 단가 + 견적 세팅 목표 마진율). 읽기 트랜잭션에서 먼저 조회.
|
||
prices = {}
|
||
if item_ids:
|
||
_err, prices = await DB_SESSION_MNG.execute_lambda(
|
||
quotations.DBType(),
|
||
DBWRType.DB_READ.value,
|
||
lambda s: self.quotation_crud.get_item_prices(s, item_ids),
|
||
)
|
||
prices = prices if _err == ErrorType.SUCCESS else {}
|
||
_err, margin = await DB_SESSION_MNG.execute_lambda(
|
||
quotations.DBType(),
|
||
DBWRType.DB_READ.value,
|
||
lambda s: self.quotation_crud.get_target_margin(s, data["qt_setting_id"]),
|
||
)
|
||
margin = margin if _err == ErrorType.SUCCESS else None
|
||
|
||
# 선택 협상카드가 있으면 새 버전을 만들어 카드들을 묶고, quotation.version_id 로 연결한다.
|
||
# (quotation↔card 는 chats 가 아니라 version → version_nego_cards/version_wild_cards 로 연결.)
|
||
version_obj = None
|
||
link_rows = []
|
||
if card_ids:
|
||
_err, card_types = await DB_SESSION_MNG.execute_lambda(
|
||
quotations.DBType(),
|
||
DBWRType.DB_READ.value,
|
||
lambda s: self.quotation_crud.classify_card_ids(s, card_ids),
|
||
)
|
||
card_types = card_types if _err == ErrorType.SUCCESS else {}
|
||
new_version_id = uuid.uuid4()
|
||
version_obj = versions(
|
||
version_id=new_version_id,
|
||
user_id=uuid.UUID(user_id),
|
||
code=0,
|
||
name=(data.get("number") or "견적버전")[:10],
|
||
)
|
||
for cid in card_ids:
|
||
t = card_types.get(cid)
|
||
if t == 1:
|
||
link_rows.append(version_nego_cards(version_id=new_version_id, nego_card_id=cid))
|
||
elif t == 2:
|
||
link_rows.append(version_wild_cards(version_id=new_version_id, wild_card_id=cid))
|
||
data["version_id"] = new_version_id
|
||
|
||
# qt_id 를 미리 발급해 세션 FK(quotation_id)와 묶고, 한 트랜잭션에 함께 insert 한다.
|
||
qt_id = uuid.uuid4()
|
||
quotation = quotations(**data, qt_id=qt_id, user_id=uuid.UUID(user_id))
|
||
|
||
session_objs = []
|
||
for iid in item_ids:
|
||
tp = self._calc_target_price(prices.get(iid), margin)
|
||
for sid in supplier_ids:
|
||
session_objs.append(
|
||
sessions(
|
||
session_id=uuid.uuid4(),
|
||
quotation_id=qt_id,
|
||
item_id=iid,
|
||
supplier_id=sid,
|
||
qt_number=quotation.number,
|
||
qt_round=quotation.round,
|
||
qt_type=quotation.type,
|
||
target_price=tp,
|
||
status=SessionStatus.CREATED.value,
|
||
end_time=quotation.end_time,
|
||
)
|
||
)
|
||
|
||
# 버전 → (버전-카드 매핑) → 견적 → 세션 순으로 한 트랜잭션에 insert(FK 순서 보장).
|
||
ops = []
|
||
if version_obj is not None:
|
||
ops.append(lambda s: self.quotation_crud.add_rows(s, [version_obj]))
|
||
ops.append(lambda s: self.quotation_crud.add_rows(s, link_rows))
|
||
ops.append(lambda s: self.quotation_crud.add_quotation(s, quotation))
|
||
ops.append(lambda s: self.quotation_crud.add_sessions(s, session_objs))
|
||
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||
[quotations.DBType()],
|
||
ops,
|
||
)
|
||
if err_type != ErrorType.SUCCESS:
|
||
res.result.SetResult(err_type)
|
||
return res
|
||
|
||
# 서버 기본값(created_at/updated_at) 로드 위해 재조회.
|
||
f_err, fresh = await DB_SESSION_MNG.execute_lambda(
|
||
quotations.DBType(),
|
||
DBWRType.DB_READ.value,
|
||
lambda s: self.quotation_crud.get_by_id(s, qt_id),
|
||
)
|
||
res.quotation = QuotationData.model_validate(fresh if f_err == ErrorType.SUCCESS and fresh is not None else quotation)
|
||
|
||
# 생성된 세션 + 각 세션 chat 실행 URL 을 함께 반환(협상리스트에서 바로 진입 가능).
|
||
res.sessions = [
|
||
SessionData(
|
||
session_id=so.session_id,
|
||
qt_id=so.quotation_id,
|
||
supplier_id=so.supplier_id,
|
||
item_id=so.item_id,
|
||
qt_number=so.qt_number,
|
||
qt_round=so.qt_round,
|
||
qt_type=so.qt_type,
|
||
target_price=so.target_price,
|
||
status=so.status,
|
||
end_time=so.end_time,
|
||
url=self._session_chat_url(so.session_id),
|
||
)
|
||
for so in session_objs
|
||
]
|
||
res.async_job = AsyncJob(status="created", message=f"협상 세션 {len(session_objs)}건 생성")
|
||
return res
|
||
|
||
async def stop_quotation(self, qt_id: str) -> Res_Quotation:
|
||
res = Res_Quotation()
|
||
qt_uuid = uuid.UUID(qt_id)
|
||
|
||
# 존재 확인
|
||
err_type, _ = await self._fetch(qt_uuid)
|
||
if err_type != ErrorType.SUCCESS:
|
||
res.result.SetResult(err_type)
|
||
return res
|
||
|
||
# 견적 '견적마감'(CLOSED) + 딸린 세션 정리를 한 트랜잭션으로.
|
||
# 세션은 아직 시작 전(협상생성)인 것만 미참여로 떨군다. 협상중/완료/거부/미참여는 그대로 둔다.
|
||
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||
[quotations.DBType()],
|
||
[
|
||
lambda s: self.quotation_crud.update_quotation(s, qt_uuid, {"status": QuotationStatus.CLOSED.value}),
|
||
lambda s: self.quotation_crud.update_sessions_status(
|
||
s, qt_uuid, [SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value], SessionStatus.NOT_PARTICIPATED.value
|
||
),
|
||
],
|
||
)
|
||
if err_type != ErrorType.SUCCESS:
|
||
res.result.SetResult(err_type)
|
||
return res
|
||
|
||
# 갱신 후 재조회
|
||
return await self.get_quotation(qt_id)
|
||
|
||
async def delete_quotation(self, qt_id: str) -> Res_DeleteQuotation:
|
||
res = Res_DeleteQuotation()
|
||
qt_uuid = uuid.UUID(qt_id)
|
||
|
||
err_type, _ = await self._fetch(qt_uuid)
|
||
if err_type != ErrorType.SUCCESS:
|
||
res.result.SetResult(err_type)
|
||
return res
|
||
|
||
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||
[quotations.DBType()],
|
||
[lambda s: self.quotation_crud.soft_delete(s, qt_uuid)],
|
||
)
|
||
if err_type != ErrorType.SUCCESS:
|
||
res.result.SetResult(err_type)
|
||
return res
|
||
|
||
async def get_status(self, qt_id: str) -> Res_QuotationStatus:
|
||
res = Res_QuotationStatus()
|
||
err_type, quotation = await self._fetch(uuid.UUID(qt_id))
|
||
if err_type != ErrorType.SUCCESS:
|
||
res.result.SetResult(err_type)
|
||
return res
|
||
res.qt_id = quotation.qt_id
|
||
res.job_status = quotation.status
|
||
res.message = "ok"
|
||
return res
|
||
|
||
async def get_result(self, qt_id: str) -> Res_QuotationResult:
|
||
res = Res_QuotationResult()
|
||
err_type, quotation = await self._fetch(uuid.UUID(qt_id))
|
||
if err_type != ErrorType.SUCCESS:
|
||
res.result.SetResult(err_type)
|
||
return res
|
||
# 낙찰 결과는 quotations 컬럼에서 직접 노출. results 테이블 미존재로 result_count 는 0.
|
||
res.qt_id = quotation.qt_id
|
||
res.winner_supplier_id = quotation.preferred_sp_id
|
||
res.winner_supplier_name = quotation.preferred_sp_name
|
||
res.is_equal_bid = quotation.equal_bid_yn
|
||
res.equal_bid_data = quotation.equal_bid_data
|
||
res.result_count = 0
|
||
return res
|
||
|
||
async def list_sessions(self, qt_id: str) -> Res_QuotationSessions:
|
||
res = Res_QuotationSessions()
|
||
qt_uuid = uuid.UUID(qt_id)
|
||
err_type, quotation = await self._fetch(qt_uuid)
|
||
if err_type != ErrorType.SUCCESS:
|
||
res.result.SetResult(err_type)
|
||
return res
|
||
|
||
err_type, rows = await DB_SESSION_MNG.execute_lambda(
|
||
sessions.DBType(),
|
||
DBWRType.DB_READ.value,
|
||
lambda s: self.quotation_crud.list_sessions(s, qt_uuid),
|
||
)
|
||
if err_type != ErrorType.SUCCESS:
|
||
res.result.SetResult(err_type)
|
||
return res
|
||
|
||
res.qt_id = quotation.qt_id
|
||
# sessions.quotation_id → SessionData.qt_id 로 명시 매핑(컬럼명 불일치).
|
||
res.sessions = [
|
||
SessionData(
|
||
session_id=r.session_id,
|
||
qt_id=r.quotation_id,
|
||
supplier_id=r.supplier_id,
|
||
item_id=r.item_id,
|
||
qt_number=r.qt_number,
|
||
qt_round=r.qt_round,
|
||
qt_type=r.qt_type,
|
||
target_price=r.target_price,
|
||
status=r.status,
|
||
bid_price=r.bid_price,
|
||
bid_at=r.bid_at,
|
||
end_time=r.end_time,
|
||
reject_reason=r.reject_reason,
|
||
reject_price=r.reject_price,
|
||
reject_delivery_type=r.reject_delivery_type,
|
||
url=self._session_chat_url(r.session_id),
|
||
)
|
||
for r in rows
|
||
]
|
||
res.total = len(res.sessions)
|
||
return res
|
||
|
||
async def list_chats(self, session_id: str) -> Res_SessionChat:
|
||
res = Res_SessionChat()
|
||
sess_uuid = uuid.UUID(session_id)
|
||
res.session_id = sess_uuid
|
||
|
||
err_type, rows = await DB_SESSION_MNG.execute_lambda(
|
||
chats.DBType(),
|
||
DBWRType.DB_READ.value,
|
||
lambda s: self.quotation_crud.list_chats(s, sess_uuid),
|
||
)
|
||
if err_type != ErrorType.SUCCESS:
|
||
res.result.SetResult(err_type)
|
||
return res
|
||
|
||
# chats.seq → ChatMessageData.index 로 매핑. indicator_value(Decimal) → float.
|
||
# 말풍선 텍스트는 chats.meta.script 에 영속화돼 있어 그대로 꺼낸다(프론트 하드코딩 X).
|
||
res.messages = [
|
||
ChatMessageData(
|
||
chat_id=r.chat_id,
|
||
session_id=r.session_id,
|
||
card_id=r.card_id,
|
||
index=r.seq,
|
||
sender=r.sender,
|
||
target_price=r.target_price,
|
||
card_used_yn=r.card_used_yn,
|
||
indicator_value=float(r.indicator_value) if r.indicator_value is not None else None,
|
||
card_type=r.card_type,
|
||
script=(r.meta or {}).get("script"),
|
||
step=(r.meta or {}).get("step"),
|
||
)
|
||
for r in rows
|
||
]
|
||
return res
|
||
|
||
async def list_cards(self, qt_id: str) -> Res_QuotationCards:
|
||
res = Res_QuotationCards()
|
||
qt_uuid = uuid.UUID(qt_id)
|
||
err_type, quotation = await self._fetch(qt_uuid)
|
||
if err_type != ErrorType.SUCCESS:
|
||
res.result.SetResult(err_type)
|
||
return res
|
||
|
||
# 견적의 버전(quotation.version_id)에 묶인 카드를 조회한다(version_nego_cards/version_wild_cards).
|
||
err_type, rows = await DB_SESSION_MNG.execute_lambda(
|
||
quotations.DBType(),
|
||
DBWRType.DB_READ.value,
|
||
lambda s: self.quotation_crud.get_version_cards(s, quotation.version_id),
|
||
)
|
||
if err_type != ErrorType.SUCCESS:
|
||
res.result.SetResult(err_type)
|
||
return res
|
||
|
||
res.qt_id = quotation.qt_id
|
||
# rows = [(card_type, card_pk, number, name, script, edit_script, condition, memo), ...].
|
||
cards = []
|
||
for card_type, card_pk, number, name, script, edit, condition, memo in rows:
|
||
is_wild = card_type == 2
|
||
cards.append(
|
||
QuotationCardData(
|
||
session_card_id=card_pk,
|
||
qt_id=quotation.qt_id,
|
||
nego_card_id=None if is_wild else card_pk,
|
||
wild_card_id=card_pk if is_wild else None,
|
||
type=card_type,
|
||
number=number,
|
||
name=name,
|
||
script=script,
|
||
edit_script=edit,
|
||
condition=condition,
|
||
memo=memo,
|
||
)
|
||
)
|
||
res.cards = cards
|
||
return res
|