QuotationCardData 에 number/edit_script/condition/memo 추가, card 카탈로그(nego/wild) 라이브 조인으로 채움. DB 컬럼 변경 없음(기존 카드 테이블 조인만 확장). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
287 lines
11 KiB
Python
287 lines
11 KiB
Python
import uuid
|
|
|
|
from fastapi import Depends
|
|
|
|
from common.database.db_session_manager import DB_SESSION_MNG
|
|
from common.database.model.models import quotations, sessions, chats
|
|
from common.enums import DBWRType, ErrorType
|
|
from common.models.gmodel import PageParams
|
|
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 는 생성 시 소유자로만 기록한다(조회/변경 시 소유권 필터 없음).
|
|
"""
|
|
|
|
def __init__(self, quotation_crud: IQuotationCRUD = Depends(QuotationCRUD)):
|
|
self.quotation_crud = quotation_crud
|
|
|
|
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, 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, status, type_, start_from, start_to, pg.skip, pg.size),
|
|
)
|
|
if err_type != ErrorType.SUCCESS:
|
|
res.result.SetResult(err_type)
|
|
return res
|
|
|
|
# 참여 협력사 수(세션 distinct supplier)를 이 페이지 견적들에 대해 한 방으로 세서 합친다(메인 쿼리 비건드림).
|
|
qt_ids = [r.qt_id for r in rows]
|
|
counts = {}
|
|
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
|
|
for r in rows:
|
|
r.participation_count = counts.get(r.qt_id, 0)
|
|
|
|
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()
|
|
quotation = quotations(**data, user_id=uuid.UUID(user_id))
|
|
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
|
[quotations.DBType()],
|
|
[lambda s: self.quotation_crud.add_quotation(s, quotation)],
|
|
)
|
|
if err_type != ErrorType.SUCCESS:
|
|
res.result.SetResult(err_type)
|
|
return res
|
|
# 서버 기본값(created_at/updated_at) 로드 위해 재조회 (응답 shape 은 Res_CreateQuotation 유지).
|
|
f_err, fresh = await DB_SESSION_MNG.execute_lambda(
|
|
quotations.DBType(),
|
|
DBWRType.DB_READ.value,
|
|
lambda s: self.quotation_crud.get_by_id(s, quotation.qt_id),
|
|
)
|
|
res.quotation = QuotationData.model_validate(fresh if f_err == ErrorType.SUCCESS and fresh is not None else quotation)
|
|
# 네고시움 백엔드 비동기 요청은 스텁이므로 row 만 생성한다.
|
|
res.async_job = AsyncJob(status="submitted", message="견적 생성 작업 요청됨(스텁)")
|
|
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
|
|
|
|
# 상태를 '견적마감'으로 변경(실제 DB 업데이트)
|
|
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
|
[quotations.DBType()],
|
|
[lambda s: self.quotation_crud.update_quotation(s, qt_uuid, {"status": "견적마감"})],
|
|
)
|
|
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,
|
|
)
|
|
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.
|
|
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,
|
|
)
|
|
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
|
|
|
|
err_type, rows = await DB_SESSION_MNG.execute_lambda(
|
|
chats.DBType(),
|
|
DBWRType.DB_READ.value,
|
|
lambda s: self.quotation_crud.list_used_cards(s, qt_uuid),
|
|
)
|
|
if err_type != ErrorType.SUCCESS:
|
|
res.result.SetResult(err_type)
|
|
return res
|
|
|
|
res.qt_id = quotation.qt_id
|
|
# rows = [(chat_row, card_id, number, name, script, edit_script, condition, memo), ...].
|
|
# nego/wild 구분은 chats.card_type. condition/memo 는 와일드카드에만 존재.
|
|
cards = []
|
|
for chat_row, nc_id, nc_number, nc_name, nc_script, nc_edit, wc_condition, wc_memo in rows:
|
|
is_wild = chat_row.card_type == 2
|
|
cards.append(
|
|
QuotationCardData(
|
|
session_card_id=chat_row.chat_id,
|
|
qt_id=quotation.qt_id,
|
|
nego_card_id=None if is_wild else nc_id,
|
|
wild_card_id=nc_id if is_wild else None,
|
|
type=chat_row.card_type if chat_row.card_type is not None else 1,
|
|
number=nc_number,
|
|
name=nc_name,
|
|
script=nc_script,
|
|
edit_script=nc_edit,
|
|
condition=wc_condition if is_wild else None,
|
|
memo=wc_memo if is_wild else None,
|
|
)
|
|
)
|
|
res.cards = cards
|
|
return res
|