o2o-negosium-original/negodata/backend/services/quotation_service.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

455 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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