협상 불가 사유를 내면 500 이 나고, 거부 폼은 목록·채팅이 따로 놀았으며,
제출한 내용을 다시 볼 방법이 없었다. 결렬 건은 협력사가 낸 거부가를 계약가로
간주해 낙찰시켜 절감 통계가 음수로 뒤집힐 수 있었고, 견적 상세는 판정 가격이
세 곳에 흩어져 대화 탭에선 아예 보이지 않았다.
agent
- 결렬 종료 로깅 크래시 수정 — _log 를 action_id 기반으로 되돌리고 선택 근거
(Q·UCB·방문수)는 decision/policy 가 있을 때만 채운다. 종료 행은 카드 선택이
없고 policy.update 뒤라 값을 넣으면 학습 화면 집계가 오염된다
negosium
- 거부 폼을 목록·채팅 공용 컴포넌트 하나로 통일(사유 3종 + 공급 희망가·의견 선택)
- 거부 사유 열람 — 목록에 '거부 사유 보기'(부가정보 보기와 같은 규격), 채팅
재진입 시 대화 끝에 거부 내역 카드. 목록·채팅 init 응답에 reject_reason·reject_price 추가
- 자유 입력 거부("협상 포기합니다")가 사유 NULL 로 저장되던 문제 수정 — 폼 마커가
없으면 원문을 사유로 쓰고, 문장 속 숫자를 희망가로 오인하지 않는다
- koreanNumber 를 전역 lib 으로 이동(공용 폼이 쓴다)
negodata
- 직접 낙찰에 계약가 입력 — 결렬·미응찰 건을 오프라인으로 다시 협상한 결과를
담당자가 확정해 넣는다. 후보는 초청 협력사 전부(가격 미제출도 포함),
계약가는 sessions.custom.offline_award 에 근거·작성자·시각과 함께 남긴다
- 통계 계약가 = 담당자 확정가 우선, 없으면 투찰가. 거부가를 계약가로 치던 파생 제거.
KPI 에 오프라인 반영 건수 추가
- 견적 상세 리모델링 — 가격 레일(앵커링가/투찰현황 · 목표가 · 타결 상한가 · 결과가)을
시트에 고정해 접힘·탭 전환에도 남기고, 스펙트럼에 타결 판정선과 구간색 추가.
라벨은 폭을 실측해 두 레인으로 배치(겹침 불가). 상품·마감시각 등 전 행 동일 컬럼 제거,
협상현황에 부가정보 노출, 1:1 은 협력사·세션상태를 결과 밴드로 올림
테스트: negosium 58 · negodata 110 통과. 프론트 빌드/린트 통과.
483 lines
21 KiB
Python
483 lines
21 KiB
Python
import uuid
|
|
from datetime import datetime, timezone
|
|
from typing import Optional
|
|
|
|
from fastapi import Depends
|
|
|
|
from common.database.db_session_manager import DB_SESSION_MNG
|
|
from common.database.model.models import chats, notifications, sessions
|
|
from common.enums import (
|
|
CloseReason,
|
|
DBWRType,
|
|
ErrorType,
|
|
NotificationType,
|
|
QuotationStatus,
|
|
RENEGOTIABLE_CLOSE_REASONS,
|
|
RenegotiationStatus,
|
|
SessionStatus,
|
|
)
|
|
from common.logger import LOG
|
|
from common.models.gmodel import UserInfo
|
|
from crud.chat_crud import ChatCRUD, IChatCRUD
|
|
from crud.session_crud import ISessionCRUD, SessionCRUD
|
|
from router.v1.negotiation.protocol import (
|
|
ListItem,
|
|
Req_ExtraInfo,
|
|
Req_Renegotiation,
|
|
Res_ExtraInfo,
|
|
Res_Participate,
|
|
Res_Reject,
|
|
Res_Renegotiation,
|
|
Res_SessionList,
|
|
)
|
|
from services.auth_service import AuthService
|
|
|
|
|
|
class NegotiationService:
|
|
"""협상 도메인 비즈니스 로직.
|
|
- 인증(계정 활성 + 저장 토큰 대조)은 AuthService.authenticate 로 위임(재사용).
|
|
- 목록은 로그인 유저의 supplier_id 로만 조회한다.
|
|
"""
|
|
|
|
# 부가정보 입력 폼을 띄우는 요약 말풍선 종류. 이 말풍선이 나온 뒤면 협상은 타결된 것으로 본다.
|
|
_SUMMARY_BOT_TYPES = ("summaryRSP", "summaryCM")
|
|
|
|
def __init__(
|
|
self,
|
|
auth: AuthService = Depends(AuthService),
|
|
session_crud: ISessionCRUD = Depends(SessionCRUD),
|
|
chat_crud: IChatCRUD = Depends(ChatCRUD),
|
|
):
|
|
self.auth = auth
|
|
self.session_crud = session_crud
|
|
self.chat_crud = chat_crud
|
|
|
|
async def list_sessions(self, user_info: UserInfo, access_token: str, status, qt_type, order: str, page: int, page_size: int, keyword: str = None, result: int = None) -> Res_SessionList:
|
|
res = Res_SessionList()
|
|
|
|
# 1) 인증 (활성 + 저장된 access 토큰 대조)
|
|
err_type, info = await self.auth.authenticate(user_info, access_token)
|
|
if err_type != ErrorType.SUCCESS:
|
|
res.result.SetResult(err_type)
|
|
return res
|
|
|
|
supplier_id = uuid.UUID(info.supplier_id)
|
|
offset = (page - 1) * page_size
|
|
|
|
# 2) 목록 조회 (NEGOTIATION Read 세션, sessions ⨝ items)
|
|
err_type, rows = await DB_SESSION_MNG.execute_lambda(
|
|
sessions.DBType(),
|
|
DBWRType.DB_READ.value,
|
|
lambda s: self.session_crud.list_by_supplier(s, supplier_id, status, qt_type, order, offset, page_size, keyword, result),
|
|
)
|
|
if err_type != ErrorType.SUCCESS:
|
|
res.result.SetResult(err_type)
|
|
return res
|
|
|
|
# 3) 총개수 (페이지네이션용)
|
|
err_type, total = await DB_SESSION_MNG.execute_lambda(
|
|
sessions.DBType(),
|
|
DBWRType.DB_READ.value,
|
|
lambda s: self.session_crud.count_by_supplier(s, supplier_id, status, qt_type, keyword, result),
|
|
)
|
|
if err_type != ErrorType.SUCCESS:
|
|
res.result.SetResult(err_type)
|
|
return res
|
|
|
|
# 같은 견적번호(체인)의 최대 차수 — 이미 다음 라운드가 있으면 재협상 요청 대상이 아니다.
|
|
max_rounds: dict = {}
|
|
for number in {r[3] for r in rows if r[3]}:
|
|
_e, mx = await DB_SESSION_MNG.execute_lambda(
|
|
sessions.DBType(), DBWRType.DB_READ.value,
|
|
lambda s, n=number: self.session_crud.chain_max_round(s, n),
|
|
)
|
|
max_rounds[number] = mx or 0
|
|
|
|
res.items = [self._to_list_item(r, max_rounds) for r in rows]
|
|
res.total = total
|
|
res.page = page
|
|
res.page_size = page_size
|
|
return res
|
|
|
|
async def save_extra_info(self, user_info: UserInfo, access_token: str, session_id_str: str, req: Req_ExtraInfo) -> Res_ExtraInfo:
|
|
"""협상완료(타결) 부가정보 저장. 견적 마감 여부와 무관하게, 본인 공급사의 '협상완료' 세션에만 허용.
|
|
|
|
_load_actionable_session 은 견적마감·마감시간을 막으므로(타결 후엔 마감됐을 수 있음) 쓰지 않고 직접 검증한다.
|
|
"""
|
|
res = Res_ExtraInfo()
|
|
|
|
# 1) 인증
|
|
err_type, info = await self.auth.authenticate(user_info, access_token)
|
|
if err_type != ErrorType.SUCCESS:
|
|
res.result.SetResult(err_type)
|
|
return res
|
|
try:
|
|
session_id = uuid.UUID(session_id_str)
|
|
except (ValueError, TypeError):
|
|
res.result.SetResult(ErrorType.NEGO_NOT_FOUND)
|
|
return res
|
|
|
|
# 2) 세션 조회 + 소유 검증
|
|
err_type, sess = await DB_SESSION_MNG.execute_lambda(
|
|
sessions.DBType(), DBWRType.DB_READ.value,
|
|
lambda s: self.session_crud.get_session_by_id(s, session_id),
|
|
)
|
|
if err_type != ErrorType.SUCCESS or sess is None:
|
|
res.result.SetResult(ErrorType.NEGO_NOT_FOUND)
|
|
return res
|
|
if str(sess.supplier_id) != info.supplier_id:
|
|
res.result.SetResult(ErrorType.NEGO_FORBIDDEN)
|
|
return res
|
|
|
|
# 3) 협상완료(타결) 세션만 부가정보 입력 허용.
|
|
# 단 '협상완료' 요약 말풍선은 chat_end=false 라 세션이 아직 협상중(2)이다
|
|
# (동의 → '협상종료' 턴에서야 완료로 전이). 폼은 요약 시점에 뜨므로 그 구간도 허용한다.
|
|
if sess.status != SessionStatus.DONE.value:
|
|
if sess.status != SessionStatus.IN_PROGRESS.value or not await self._is_after_summary(sess.session_id):
|
|
res.result.SetResult(ErrorType.NEGO_NOT_PARTICIPABLE)
|
|
return res
|
|
|
|
# 4) 저장(supplier_id 가드 crud)
|
|
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
|
[sessions.DBType()],
|
|
[lambda s: self.session_crud.update_session_custom(s, session_id, uuid.UUID(info.supplier_id), req.custom or {})],
|
|
)
|
|
if err_type != ErrorType.SUCCESS:
|
|
res.result.SetResult(err_type)
|
|
return res
|
|
|
|
res.session_id = str(session_id)
|
|
return res
|
|
|
|
@staticmethod
|
|
def _to_list_item(r, max_rounds: dict) -> ListItem:
|
|
"""세션 행 → 목록 아이템. 재협상 요청 가능 여부는 서버가 판정해 내려준다(프론트가 규칙을 몰라도 되게)."""
|
|
custom = r[9] or {}
|
|
renego = custom.get("renegotiation") or {}
|
|
status = renego.get("status") or 0
|
|
|
|
is_last_round = (r[12] or 0) >= max_rounds.get(r[3], 0)
|
|
renegotiable = (
|
|
r[10] == QuotationStatus.CLOSED.value
|
|
and r[11] in RENEGOTIABLE_CLOSE_REASONS
|
|
and is_last_round
|
|
and status
|
|
not in (
|
|
RenegotiationStatus.PENDING.value,
|
|
RenegotiationStatus.APPROVED.value,
|
|
RenegotiationStatus.REJECTED.value,
|
|
)
|
|
)
|
|
return ListItem(
|
|
session_id=str(r[0]),
|
|
session_status=r[1],
|
|
qt_type=r[2],
|
|
qt_number=r[3],
|
|
qt_end_time=r[4].isoformat(timespec="seconds") if r[4] else "",
|
|
item_code=r[5] or "",
|
|
item_name=r[6] or "",
|
|
model_name=r[7] or "",
|
|
maker_name=r[8] or "",
|
|
custom=custom,
|
|
renegotiable=renegotiable,
|
|
renegotiation_status=status,
|
|
renegotiation_memo=renego.get("memo") or "",
|
|
result=NegotiationService._to_result(r[10], r[11], r[13], r[14]),
|
|
has_chat=bool(r[15]),
|
|
reject_reason=r[16] or "",
|
|
reject_price=r[17],
|
|
)
|
|
|
|
@staticmethod
|
|
def _to_result(qt_status, close_reason, winner_id, my_id) -> int:
|
|
"""공급사 관점 협상 결과(SessionResult). 견적 마감 전이면 0(미정).
|
|
낙찰 건은 낙찰자가 나면 1(낙찰)·아니면 2(미낙찰), 개찰(OPEN_*) 마감은 3(결렬=재협상 대상)."""
|
|
if qt_status != QuotationStatus.CLOSED.value:
|
|
return 0
|
|
if close_reason == CloseReason.AWARDED.value:
|
|
return 1 if winner_id is not None and str(winner_id) == str(my_id) else 2
|
|
if close_reason in RENEGOTIABLE_CLOSE_REASONS:
|
|
return 3
|
|
return 0
|
|
|
|
async def request_renegotiation(
|
|
self, user_info: UserInfo, access_token: str, session_id_str: str, req: Req_Renegotiation
|
|
) -> Res_Renegotiation:
|
|
"""결렬(개찰) 마감 건에 대해 공급사가 재협상을 요청한다(IMK #15).
|
|
전용 테이블 없이 sessions.custom.renegotiation 에 기록하고, 견적 작성자에게 알림을 남긴다."""
|
|
res = Res_Renegotiation()
|
|
|
|
err_type, info, sess, quote = await self._load_renegotiable(user_info, access_token, session_id_str)
|
|
if err_type != ErrorType.SUCCESS:
|
|
res.result.SetResult(err_type)
|
|
return res
|
|
|
|
# 심사 대기·승인·반려 건은 재요청을 막는다(전용 테이블이 없어 유니크 대신 여기서 검증).
|
|
# 반려는 담당자가 이미 판단한 결과라 같은 건으로 다시 올릴 수 없다. 철회(CANCELED)만 재요청 허용.
|
|
current = (sess.custom or {}).get("renegotiation") or {}
|
|
if current.get("status") in (
|
|
RenegotiationStatus.PENDING.value,
|
|
RenegotiationStatus.APPROVED.value,
|
|
RenegotiationStatus.REJECTED.value,
|
|
):
|
|
res.result.SetResult(ErrorType.NEGO_NOT_PARTICIPABLE)
|
|
return res
|
|
|
|
payload = {
|
|
"status": RenegotiationStatus.PENDING.value,
|
|
"reason": (req.reason or "").strip(),
|
|
"desired_price": req.desired_price,
|
|
"requested_at": datetime.now(timezone.utc).isoformat(),
|
|
}
|
|
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
|
[sessions.DBType()],
|
|
[lambda s: self.session_crud.merge_session_custom(
|
|
s, sess.session_id, uuid.UUID(info.supplier_id), {"renegotiation": payload}
|
|
)],
|
|
)
|
|
if err_type != ErrorType.SUCCESS:
|
|
res.result.SetResult(err_type)
|
|
return res
|
|
|
|
await self._notify_renegotiation(quote, sess, info, payload)
|
|
res.session_id = str(sess.session_id)
|
|
res.status = RenegotiationStatus.PENDING.value
|
|
return res
|
|
|
|
async def cancel_renegotiation(self, user_info: UserInfo, access_token: str, session_id_str: str) -> Res_Renegotiation:
|
|
"""공급사가 자기 요청을 철회한다. 심사 대기(PENDING) 중에만 가능."""
|
|
res = Res_Renegotiation()
|
|
|
|
err_type, info, sess, _quote = await self._load_renegotiable(user_info, access_token, session_id_str)
|
|
if err_type != ErrorType.SUCCESS:
|
|
res.result.SetResult(err_type)
|
|
return res
|
|
|
|
current = (sess.custom or {}).get("renegotiation") or {}
|
|
if current.get("status") != RenegotiationStatus.PENDING.value:
|
|
res.result.SetResult(ErrorType.NEGO_NOT_PARTICIPABLE)
|
|
return res
|
|
|
|
patch = {**current, "status": RenegotiationStatus.CANCELED.value}
|
|
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
|
[sessions.DBType()],
|
|
[lambda s: self.session_crud.merge_session_custom(
|
|
s, sess.session_id, uuid.UUID(info.supplier_id), {"renegotiation": patch}
|
|
)],
|
|
)
|
|
if err_type != ErrorType.SUCCESS:
|
|
res.result.SetResult(err_type)
|
|
return res
|
|
|
|
res.session_id = str(sess.session_id)
|
|
res.status = RenegotiationStatus.CANCELED.value
|
|
return res
|
|
|
|
async def _load_renegotiable(self, user_info: UserInfo, access_token: str, session_id_str: str):
|
|
"""재협상 요청 자격 검증 — 인증 → 본인 세션 → 결렬(개찰) 마감 → 마지막 라운드.
|
|
성공 시 (SUCCESS, info, sess, quote)."""
|
|
err_type, info = await self.auth.authenticate(user_info, access_token)
|
|
if err_type != ErrorType.SUCCESS:
|
|
return err_type, None, None, None
|
|
try:
|
|
session_id = uuid.UUID(session_id_str)
|
|
except (ValueError, TypeError):
|
|
return ErrorType.NEGO_NOT_FOUND, None, None, None
|
|
|
|
err_type, sess = await DB_SESSION_MNG.execute_lambda(
|
|
sessions.DBType(), DBWRType.DB_READ.value,
|
|
lambda s: self.session_crud.get_session_by_id(s, session_id),
|
|
)
|
|
if err_type != ErrorType.SUCCESS or sess is None:
|
|
return ErrorType.NEGO_NOT_FOUND, None, None, None
|
|
if str(sess.supplier_id) != info.supplier_id:
|
|
return ErrorType.NEGO_FORBIDDEN, None, None, None
|
|
|
|
err_type, quote = await DB_SESSION_MNG.execute_lambda(
|
|
sessions.DBType(), DBWRType.DB_READ.value,
|
|
lambda s: self.session_crud.get_quotation_by_id(s, sess.quotation_id),
|
|
)
|
|
if err_type != ErrorType.SUCCESS or quote is None:
|
|
return ErrorType.NEGO_NOT_FOUND, None, None, None
|
|
|
|
# 낙찰됐거나 아직 진행 중인 건은 요청 대상이 아니다.
|
|
if quote.status != QuotationStatus.CLOSED.value or quote.close_reason not in RENEGOTIABLE_CLOSE_REASONS:
|
|
return ErrorType.NEGO_NOT_PARTICIPABLE, None, None, None
|
|
|
|
# 이미 다음 라운드가 만들어졌으면 요청할 이유가 없다.
|
|
_e, max_round = await DB_SESSION_MNG.execute_lambda(
|
|
sessions.DBType(), DBWRType.DB_READ.value,
|
|
lambda s: self.session_crud.chain_max_round(s, quote.number),
|
|
)
|
|
if max_round and quote.round < max_round:
|
|
return ErrorType.NEGO_NOT_PARTICIPABLE, None, None, None
|
|
|
|
return ErrorType.SUCCESS, info, sess, quote
|
|
|
|
async def _notify_renegotiation(self, quote, sess, info, payload: dict) -> None:
|
|
"""견적 작성자 인박스에 재협상 요청 알림을 남긴다. 부가 효과라 실패해도 본 흐름을 막지 않는다."""
|
|
notif = notifications(
|
|
user_id=quote.user_id,
|
|
type=NotificationType.RENEGO_REQUESTED.value,
|
|
ref_qt_id=quote.qt_id,
|
|
ref_session_id=sess.session_id,
|
|
data={
|
|
"supplier_name": info.supplier_name,
|
|
"qt_number": quote.number,
|
|
"qt_round": quote.round,
|
|
"reason": payload.get("reason"),
|
|
"desired_price": payload.get("desired_price"),
|
|
},
|
|
)
|
|
err = await DB_SESSION_MNG.execute_lambda_run(
|
|
[notifications.DBType()],
|
|
[lambda s: DB_SESSION_MNG.insert(s, notif, raise_error=False)],
|
|
)
|
|
if err != ErrorType.SUCCESS:
|
|
LOG.e_no_callstack(f"[renego] 알림 기록 실패 qt={quote.qt_id} session={sess.session_id}")
|
|
|
|
async def _is_after_summary(self, session_id) -> bool:
|
|
"""마지막 말풍선이 타결 요약(summaryRSP/CM)인지 — 즉 협상이 타결된 뒤인지."""
|
|
err_type, (_, _, last_meta) = await DB_SESSION_MNG.execute_lambda(
|
|
chats.DBType(), DBWRType.DB_READ.value,
|
|
lambda s: self.chat_crud.get_last(s, session_id),
|
|
)
|
|
if err_type != ErrorType.SUCCESS or not last_meta:
|
|
return False
|
|
return last_meta.get("bot_chat_type") in self._SUMMARY_BOT_TYPES
|
|
|
|
async def _load_actionable_session(self, user_info: UserInfo, access_token: str, session_id_str: str, blocked_statuses: tuple):
|
|
"""참여/거부 공통 전처리: 인증 → 세션/견적 로드 → 소유·상태·견적마감·마감시간 검증.
|
|
성공 시 (SUCCESS, sess, quote), 실패 시 (err_type, None, None) 을 반환한다.
|
|
blocked_statuses 에 해당하는 세션 상태면 NEGO_NOT_PARTICIPABLE 로 막는다.
|
|
"""
|
|
# 1) 인증 (활성 + 저장된 access 토큰 대조)
|
|
err_type, info = await self.auth.authenticate(user_info, access_token)
|
|
if err_type != ErrorType.SUCCESS:
|
|
return err_type, None, None
|
|
try:
|
|
session_id = uuid.UUID(session_id_str)
|
|
except (ValueError, TypeError):
|
|
return ErrorType.NEGO_NOT_FOUND, None, None
|
|
|
|
# 2) 세션 조회
|
|
err_type, sess = await DB_SESSION_MNG.execute_lambda(
|
|
sessions.DBType(),
|
|
DBWRType.DB_READ.value,
|
|
lambda s: self.session_crud.get_session_by_id(s, session_id),
|
|
)
|
|
if err_type != ErrorType.SUCCESS or sess is None:
|
|
return ErrorType.NEGO_NOT_FOUND, None, None
|
|
|
|
# 3) 소유 검증 (세션 공급사 == 접속 유저 공급사)
|
|
if str(sess.supplier_id) != info.supplier_id:
|
|
return ErrorType.NEGO_FORBIDDEN, None, None
|
|
|
|
# 4) 세션 상태 검증 (호출부가 지정한 불가 상태)
|
|
if sess.status in blocked_statuses:
|
|
return ErrorType.NEGO_NOT_PARTICIPABLE, None, None
|
|
|
|
# 5) 견적 조회 + 마감 상태
|
|
err_type, quote = await DB_SESSION_MNG.execute_lambda(
|
|
sessions.DBType(),
|
|
DBWRType.DB_READ.value,
|
|
lambda s: self.session_crud.get_quotation_by_id(s, sess.quotation_id),
|
|
)
|
|
if err_type != ErrorType.SUCCESS or quote is None:
|
|
return ErrorType.NEGO_NOT_FOUND, None, None
|
|
|
|
# 협상완료 세션은 결과 열람용 재진입(무변경)이므로 견적마감·마감시간 검증을 건너뛴다.
|
|
# reject 는 blocked_statuses 로 DONE 을 이미 막으므로 이 분기는 participate 에만 닿는다.
|
|
if sess.status == SessionStatus.DONE.value:
|
|
return ErrorType.SUCCESS, sess, quote
|
|
|
|
if quote.status == QuotationStatus.CLOSED.value:
|
|
return ErrorType.NEGO_QUOTATION_CLOSED, None, None
|
|
|
|
# 6) 마감 시간 초과 (견적 end_time < 현재). 협상생성(1)일 때만 session→미참여로 정리.
|
|
end = quote.end_time
|
|
if end is not None and end.tzinfo is None:
|
|
end = end.replace(tzinfo=timezone.utc)
|
|
if end is not None and end < datetime.now(timezone.utc):
|
|
if sess.status == SessionStatus.CREATED.value:
|
|
await DB_SESSION_MNG.execute_lambda_run(
|
|
[sessions.DBType()],
|
|
[lambda s: self.session_crud.update_session_status(s, sess.session_id, SessionStatus.NOT_PARTICIPATED.value)],
|
|
)
|
|
return ErrorType.NEGO_DEADLINE_PASSED, None, None
|
|
|
|
return ErrorType.SUCCESS, sess, quote
|
|
|
|
async def participate(self, user_info: UserInfo, access_token: str, session_id_str: str) -> Res_Participate:
|
|
res = Res_Participate()
|
|
|
|
# 미참여/협상거부 상태는 참여 불가
|
|
err_type, sess, _ = await self._load_actionable_session(
|
|
user_info,
|
|
access_token,
|
|
session_id_str,
|
|
(SessionStatus.NOT_PARTICIPATED.value, SessionStatus.REJECTED.value),
|
|
)
|
|
if err_type != ErrorType.SUCCESS:
|
|
res.result.SetResult(err_type)
|
|
return res
|
|
|
|
# 참여 성공 — 협상생성(1)일 때만 상태 전이(협상중/완료는 무변경 진입)
|
|
if sess.status == SessionStatus.CREATED.value:
|
|
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
|
[sessions.DBType()],
|
|
[
|
|
lambda s: self.session_crud.update_session_status(s, sess.session_id, SessionStatus.IN_PROGRESS.value),
|
|
lambda s: self.session_crud.update_quotation_status(s, sess.quotation_id, QuotationStatus.IN_PROGRESS.value),
|
|
],
|
|
)
|
|
if err_type != ErrorType.SUCCESS:
|
|
res.result.SetResult(err_type)
|
|
return res
|
|
|
|
res.session_id = str(sess.session_id)
|
|
return res
|
|
|
|
async def reject(
|
|
self, user_info: UserInfo, access_token: str, session_id_str: str, reject_reason: str,
|
|
reject_price: Optional[int] = None, opinion: Optional[str] = None,
|
|
) -> Res_Reject:
|
|
res = Res_Reject()
|
|
|
|
# 거부 사유 필수
|
|
reason = (reject_reason or "").strip()[:255]
|
|
if not reason:
|
|
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
|
|
return res
|
|
|
|
# 협상완료/미참여/협상거부 상태는 거부 불가 (참여와 공통 검증 재사용)
|
|
err_type, sess, _ = await self._load_actionable_session(
|
|
user_info,
|
|
access_token,
|
|
session_id_str,
|
|
(SessionStatus.DONE.value, SessionStatus.NOT_PARTICIPATED.value, SessionStatus.REJECTED.value),
|
|
)
|
|
if err_type != ErrorType.SUCCESS:
|
|
res.result.SetResult(err_type)
|
|
return res
|
|
|
|
# 거부 처리 — 세션을 협상거부로 전이하고 사유·공급 희망 가격 저장.
|
|
# 의견은 부가정보와 같은 custom 컬럼이라 병합(덮어쓰기 금지) — 채팅 결렬 폼과 같은 자리.
|
|
funcs = [
|
|
lambda s: self.session_crud.update_session_reject(
|
|
s, sess.session_id, SessionStatus.REJECTED.value, reason, reject_price,
|
|
)
|
|
]
|
|
note = (opinion or "").strip()[:255]
|
|
if note:
|
|
funcs.append(
|
|
lambda s: self.session_crud.merge_session_custom(s, sess.session_id, sess.supplier_id, {"opinion": note})
|
|
)
|
|
err_type = await DB_SESSION_MNG.execute_lambda_run([sessions.DBType()], funcs)
|
|
if err_type != ErrorType.SUCCESS:
|
|
res.result.SetResult(err_type)
|
|
return res
|
|
|
|
res.session_id = str(sess.session_id)
|
|
return res
|