o2o-negosium-original/negodata/backend/services/quotation/queries.py
Mina Choi 1a859b198c [fix] negodata: 직접 낙찰 낙찰가가 계약가 대신 거부가로 표시되던 문제
세션 목록 API(queries.py)가 SessionData 를 필드별로 수동 조립하는데 contract_price 를
빠뜨려, 담당자가 입력한 계약가가 응답에 안 실렸다(항상 null). 그 결과 프론트가 계약가를
못 읽고 제출가(거부가/투찰가)로 폴백해, 14,000,000 으로 직접 낙찰해도 낙찰가에 거부가가
떴다. 프로토콜·ORM·컬럼은 이미 있었으나 이 직렬화 한 줄이 누락돼 있었다.

- queries.py: SessionData 조립에 contract_price=r.contract_price 추가
- ResultSummaryBand: 스펙트럼 낙찰점도 제출가(awardPrice)가 아니라 계약가(contractPrice)로 찍어
  레일 낙찰가와 일치시킴

검증: 세션 API 응답에 contract_price 실제 내려오는 것 확인(화면 아닌 응답 직접). negodata 테스트 통과.
2026-08-12 11:02:10 +09:00

323 lines
14 KiB
Python

"""견적 조회(목록·상세·상태·결과·세션·채팅·카드)·삭제와 공용 단건조회(_fetch)."""
import uuid
from common.authz import is_owner_or_admin
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 router.v1.quotation.protocol import (
ChatMessageData,
QuotationCardData,
QuotationData,
SessionData,
Res_DeleteQuotation,
Res_Quotation,
Res_QuotationCards,
Res_QuotationList,
Res_QuotationResult,
Res_QuotationSessions,
Res_QuotationStatus,
Res_SessionChat,
)
class QueriesMixin:
async def _fetch(self, qt_id: uuid.UUID, company_id=None, user_id=None, role=None):
"""견적 단건 조회. (ErrorType, quotation|None) 반환.
company_id 가 주어지면 회사 스코프(작성자 회사) 가드 — 남의 회사 견적은 NOT_FOUND. 내부/스케줄러 호출은 None.
user_id 가 주어지면 조회 게이팅 — 일반(USER)은 본인 견적만, 남의 견적은 존재를 숨긴다(NOT_FOUND).
변경 액션(삭제·마감 등)은 안내 문구가 필요해 user_id 없이 부르고 각자 ACCOUNT_FORBIDDEN 게이트를 탄다."""
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, company_id),
)
if err_type != ErrorType.SUCCESS or quotation is None:
return ErrorType.QUOTATION_NOT_FOUND, None
if user_id is not None and not is_owner_or_admin(quotation.user_id, user_id, role):
return ErrorType.QUOTATION_NOT_FOUND, None
return ErrorType.SUCCESS, quotation
async def list_quotations(self, company_id, owner, search, status, type_, start_from, start_to, pg: PageParams) -> Res_QuotationList:
"""견적 목록. 회사(company_id) 스코프로 제한하고, owner(user_id) 가 주어지면 '내 견적만'으로 더 좁힌다."""
res = Res_QuotationList(page=pg.page, size=pg.size)
company_uuid = uuid.UUID(company_id)
owner_uuid = uuid.UUID(owner) if owner else None
err_type, rows, total = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(),
DBWRType.DB_READ.value,
lambda s: self.quotation_crud.search(s, company_uuid, owner_uuid, 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)·작성자명(user→users.name)을
# 이 페이지 견적들에 대해 각각 한 방으로 모아 합친다(메인 쿼리 비건드림).
qt_ids = [r.qt_id for r in rows]
counts = {}
item_map = {}
name_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
user_ids = list({r.user_id for r in rows})
nm_err, got_nm = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(),
DBWRType.DB_READ.value,
lambda s: self.quotation_crud.user_name_map(s, user_ids),
)
if nm_err == ErrorType.SUCCESS:
name_map = got_nm
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
r.creator_name = name_map.get(r.user_id)
res.quotations = [QuotationData.model_validate(r) for r in rows]
res.total = total
return res
async def get_quotation(self, qt_id: str, company_id=None, user_id=None, role=None) -> Res_Quotation:
res = Res_Quotation()
err_type, quotation = await self._fetch(uuid.UUID(qt_id), company_id, user_id, role)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
res.quotation = QuotationData.model_validate(quotation)
return res
async def delete_quotation(self, qt_id: str, company_id=None, user_id=None, role=None) -> Res_DeleteQuotation:
res = Res_DeleteQuotation()
qt_uuid = uuid.UUID(qt_id)
err_type, original = await self._fetch(qt_uuid, company_id)
if err_type != ErrorType.SUCCESS or original is None:
res.result.SetResult(err_type)
return res
# 소유자 게이팅 — 본인 견적 또는 최고관리자만 삭제(user_id 미지정=내부 호출은 스킵).
if user_id is not None and not is_owner_or_admin(original.user_id, user_id, role):
res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN)
res.msg = "본인이 생성한 견적만 삭제할 수 있습니다."
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, company_id=None, user_id=None, role=None) -> Res_QuotationStatus:
res = Res_QuotationStatus()
err_type, quotation = await self._fetch(uuid.UUID(qt_id), company_id, user_id, role)
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, company_id=None, user_id=None, role=None) -> Res_QuotationResult:
res = Res_QuotationResult()
err_type, quotation = await self._fetch(uuid.UUID(qt_id), company_id, user_id, role)
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, company_id=None, user_id=None, role=None) -> Res_QuotationSessions:
res = Res_QuotationSessions()
qt_uuid = uuid.UUID(qt_id)
err_type, quotation = await self._fetch(qt_uuid, company_id, user_id, role)
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,
anchoring_price=r.anchoring_price,
done_ceiling_price=r.done_ceiling_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,
contract_price=r.contract_price,
reject_delivery_type=r.reject_delivery_type,
email_sent_at=r.email_sent_at,
custom=r.custom,
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, company_id=None, user_id=None, role=None) -> Res_SessionChat:
res = Res_SessionChat()
sess_uuid = uuid.UUID(session_id)
res.session_id = sess_uuid
# 회사 가드: chats 는 session 키라 세션→견적→회사로 확인한다(남의 회사 세션이면 NOT_FOUND).
if company_id is not None:
g_err, got = await DB_SESSION_MNG.execute_lambda(
sessions.DBType(),
DBWRType.DB_READ.value,
lambda s: self.quotation_crud.get_session_with_supplier(s, sess_uuid),
)
if g_err != ErrorType.SUCCESS or got is None:
res.result.SetResult(g_err if g_err != ErrorType.SUCCESS else ErrorType.QUOTATION_NOT_FOUND)
return res
guard_err, _ = await self._fetch(got[0].quotation_id, company_id, user_id, role)
if guard_err != ErrorType.SUCCESS:
res.result.SetResult(guard_err)
return res
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, company_id=None, user_id=None, role=None) -> Res_QuotationCards:
res = Res_QuotationCards()
qt_uuid = uuid.UUID(qt_id)
err_type, quotation = await self._fetch(qt_uuid, company_id, user_id, role)
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,
)
)
# 봇은 버전 카드셋 밖에서도 카드를 고르므로(RL 런타임 미제한) 채팅의 사용 카드(chats.card_id)가
# 버전 목록에 없으면 화면·JSON 내보내기의 카드 매칭이 전부 null 이 된다 → 실사용 카드를 합쳐 내려준다.
# 부가 조회라 실패 시 버전 카드만으로 응답한다(목록의 참여수 집계와 같은 best-effort 패턴).
used_err, used_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 used_err == ErrorType.SUCCESS:
seen = {c.session_card_id for c in cards}
for chat, card_pk, number, name, script, edit, condition, memo in used_rows:
# card_pk 미해석(카드 삭제 등)이면 건너뛴다 — 매칭할 카탈로그 정보가 없다.
if card_pk is None or card_pk in seen:
continue
seen.add(card_pk)
is_wild = chat.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=chat.card_type,
number=number,
name=name,
script=script,
edit_script=edit,
condition=condition,
memo=memo,
)
)
res.cards = cards
return res