- company.notifications 테이블 + NotificationType(SUCCESS/REGENERATED/FAILURE) - 백엔드 알림 조회/읽음 API + close_and_decide 결과 분기마다 알림 생성 - 헤더 알림 벨(안읽음 배지) + 알림 페이지(읽음·딥링크) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1034 lines
52 KiB
Python
1034 lines
52 KiB
Python
import re
|
||
import uuid
|
||
from datetime import timezone, timedelta
|
||
from typing import Optional
|
||
|
||
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 CloseOutcome, DBWRType, ErrorType, NotificationType, QuotationStatus, QuotationType, SessionStatus
|
||
from common.logger import LOG
|
||
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 (
|
||
ChatMessageData,
|
||
QuotationCardData,
|
||
QuotationData,
|
||
SessionData,
|
||
Req_CreateQuotation,
|
||
Res_CreateQuotation,
|
||
Res_DeleteQuotation,
|
||
Res_LastSupplierType,
|
||
Res_NotifySessions,
|
||
Res_Quotation,
|
||
Res_QuotationCards,
|
||
Res_QuotationList,
|
||
Res_QuotationResult,
|
||
Res_QuotationSessions,
|
||
Res_QuotationStatus,
|
||
Res_SessionChat,
|
||
Res_TargetBreakdown,
|
||
TargetCandidate,
|
||
)
|
||
from services.email import EmailUnavailable, build_invite_email, send_email
|
||
from services.notification import create_notification
|
||
|
||
|
||
class QuotationService:
|
||
"""견적 비즈니스 로직.
|
||
|
||
회사 스코프(멀티테넌트)는 작성자(user_id)→users.company_id 조인으로 건다(quotations 에 company_id 컬럼이 없음).
|
||
목록(list_quotations)은 회사 스코프로 제한한다. user_id 는 '내 견적만' 추가 필터로도 쓴다.
|
||
"""
|
||
|
||
# 기본 전략 버전(card.versions 시드). 견적 생성 시 version_id 미지정이면 이 값으로 채운다.
|
||
DEFAULT_VERSION_ID = uuid.UUID("00000000-0000-0000-0000-000000000030")
|
||
|
||
# 재생성 한도: 한 체인(같은 견적번호)에서 사유(미참여/동가)별 최대 1번까지 재생성(순서 무관, 같은 사유 2번 불가).
|
||
MAX_REGEN_PER_CAUSE = 1
|
||
|
||
# 재생성 라운드의 최소 협상기간(방어적 하한). 원본 협상기간이 비정상적으로 짧으면(또는 0/음수면)
|
||
# 새 라운드가 생성 즉시 만료돼 다음 크론 tick(*/5분)에 또 마감되는 연쇄를 막는다.
|
||
# 정상 견적(수 시간~수일)은 원본 기간을 그대로 쓰며, 이 하한은 비정상적으로 짧은 경우에만 적용된다.
|
||
# TODO 하한값 변경 해야함 !!! feat. MarineYang
|
||
MIN_REGEN_DURATION = timedelta(hours=1)
|
||
|
||
# 인터넷 평균 수수료율(상수). 시장 평균값이라 견적/세팅별로 두지 않고 고정. 목표가=인터넷최저가×(1−값).
|
||
INTERNET_AVERAGE_FEE = 0.078
|
||
|
||
# 목표가 후보 basis 코드 ↔ 표시 라벨(산정내역 응답에서 프론트가 그대로 표기).
|
||
_CANDIDATE_LABELS = {"md": "MD 입력가", "internet": "인터넷 최저가", "purchase": "매입가", "selling": "판매가"}
|
||
|
||
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 _candidates(md_price=None, internet_lowest=None, purchase=None, selling=None, fee=0.0, margin=0.0, is_new=False):
|
||
"""목표가 후보 [(basis, value_float)] 목록(빈 값/0 은 제외). md 있으면 md 단독.
|
||
값은 float(인터넷=가격×(1−수수료), 판매가=가격×(1−마진))이며 채택 시 int() 절삭한다.
|
||
_calc_target_price(생성)와 get_target_breakdown(표시)가 공유하는 단일 산정 로직."""
|
||
if md_price:
|
||
return [("md", float(int(md_price)))]
|
||
out = []
|
||
if internet_lowest:
|
||
out.append(("internet", int(internet_lowest) * (1 - (fee or 0.0))))
|
||
if not is_new: # 재(협상·견적)만 매입가·판매가를 후보에 추가. 신규는 인터넷최저가만.
|
||
if purchase:
|
||
out.append(("purchase", float(int(purchase))))
|
||
if selling:
|
||
out.append(("selling", int(selling) * (1 - (margin or 0.0))))
|
||
return out
|
||
|
||
@staticmethod
|
||
def _calc_target_price(md_price=None, internet_lowest=None, purchase=None, selling=None, fee=0.0, margin=0.0, is_new=False) -> int:
|
||
"""세션 목표가 (KTC 신규/재 분리 로직, 회사 데이터 풍부도에 graceful 적응)
|
||
① md_price 있으면 → 그대로
|
||
② 없으면:
|
||
· 신규(NEW_NEGO/NEW_QUOTE) → 인터넷최저가 × (1 − fee) [인터넷최저가만]
|
||
· 재(RENEGO/REQUOTE) → 유효 후보 중 min:
|
||
- 인터넷최저가 × (1 − fee) ← fee=quotation_settings.internet_average_fee
|
||
- 매입가 (그대로)
|
||
- 판매가 × (1 − margin) ← margin=quotation_settings.target_margin_rate
|
||
③ 후보 0개 → 견적 생성 불가(ValueError)."""
|
||
if not md_price:
|
||
# 율은 비율(0~1 미만)이어야 한다. 1 이상이면 (1−율)≤0 → 목표가가 0/음수가 되므로 설정 오류로 막는다.
|
||
if not 0.0 <= (fee or 0.0) < 1.0:
|
||
raise ValueError(f"인터넷 수수료율은 0 이상 1 미만이어야 합니다: fee={fee}")
|
||
if not is_new and not 0.0 <= (margin or 0.0) < 1.0:
|
||
raise ValueError(f"목표 마진율은 0 이상 1 미만이어야 합니다: margin={margin}")
|
||
cands = QuotationService._candidates(md_price, internet_lowest, purchase, selling, fee, margin, is_new)
|
||
if not cands:
|
||
raise ValueError("타겟 가격 계산 불가: md_price·인터넷최저가" + ("" if is_new else "·매입가·판매가") + " 모두 없음")
|
||
return int(min(v for _, v in cands))
|
||
|
||
@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, company_id=None):
|
||
"""견적 단건 조회. (ErrorType, quotation|None) 반환.
|
||
company_id 가 주어지면 회사 스코프(작성자 회사) 가드 — 남의 회사 견적은 NOT_FOUND. 내부/스케줄러 호출은 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, company_id),
|
||
)
|
||
if err_type != ErrorType.SUCCESS or quotation is None:
|
||
return ErrorType.QUOTATION_NOT_FOUND, None
|
||
return ErrorType.SUCCESS, quotation
|
||
|
||
async def get_target_breakdown(self, session_id: str, company_id=None) -> Res_TargetBreakdown:
|
||
"""세션 목표가 산정내역(후보·채택). 저장된 target_price/anchoring 은 그대로 표기하고,
|
||
후보값은 생성과 동일한 _candidates 로직으로 계산해 내려준다(프론트 재계산 제거 → 항상 일치).
|
||
상속분(재생성 라운드)은 현재 후보와 무관하므로 is_inherited=True, 채택 표시는 비운다."""
|
||
res = Res_TargetBreakdown()
|
||
err_type, got = await DB_SESSION_MNG.execute_lambda(
|
||
sessions.DBType(),
|
||
DBWRType.DB_READ.value,
|
||
lambda s: self.quotation_crud.get_session_with_supplier(s, uuid.UUID(session_id)),
|
||
)
|
||
if err_type != ErrorType.SUCCESS or got is None:
|
||
res.result.SetResult(err_type if err_type != ErrorType.SUCCESS else ErrorType.QUOTATION_NOT_FOUND)
|
||
return res
|
||
sess = got[0]
|
||
err_type, quotation = await self._fetch(sess.quotation_id, company_id)
|
||
if err_type != ErrorType.SUCCESS:
|
||
res.result.SetResult(err_type)
|
||
return res
|
||
|
||
_e, prices = await DB_SESSION_MNG.execute_lambda(
|
||
quotations.DBType(),
|
||
DBWRType.DB_READ.value,
|
||
lambda s: self.quotation_crud.get_item_prices(s, [sess.item_id]),
|
||
)
|
||
internet, purchase, selling = (prices or {}).get(sess.item_id) or (None, None, None)
|
||
_e, rates = await DB_SESSION_MNG.execute_lambda(
|
||
quotations.DBType(),
|
||
DBWRType.DB_READ.value,
|
||
lambda s: self.quotation_crud.get_setting_rates(s, quotation.qt_setting_id),
|
||
)
|
||
rates = rates or {}
|
||
fee = self.INTERNET_AVERAGE_FEE
|
||
margin = rates.get("margin") or 0.0
|
||
anchoring = rates.get("anchoring") or 0.0
|
||
is_new = QuotationType.is_new(quotation.type)
|
||
md = quotation.md_price
|
||
|
||
cands = self._candidates(md, internet, purchase, selling, fee, margin, is_new)
|
||
chosen_basis, computed = None, None
|
||
if cands:
|
||
chosen_basis, chosen_val = min(cands, key=lambda c: c[1])
|
||
computed = int(chosen_val)
|
||
is_inherited = computed is None or computed != sess.target_price
|
||
|
||
res.is_new = is_new
|
||
res.is_inherited = is_inherited
|
||
res.md_price = int(md) if md else None
|
||
res.internet_lowest = int(internet) if internet is not None else None
|
||
res.purchase = int(purchase) if purchase is not None else None
|
||
res.selling = int(selling) if selling is not None else None
|
||
res.fee = fee
|
||
res.margin = margin
|
||
res.anchoring_value = anchoring
|
||
res.candidates = [TargetCandidate(basis=b, label=self._CANDIDATE_LABELS.get(b, b), value=int(v)) for b, v in cands]
|
||
res.chosen_basis = None if is_inherited else chosen_basis
|
||
res.target_price = sess.target_price
|
||
res.target_anchoring_price = sess.target_anchoring_price
|
||
return res
|
||
|
||
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) -> Res_Quotation:
|
||
res = Res_Quotation()
|
||
err_type, quotation = await self._fetch(uuid.UUID(qt_id), company_id)
|
||
if err_type != ErrorType.SUCCESS:
|
||
res.result.SetResult(err_type)
|
||
return res
|
||
res.quotation = QuotationData.model_validate(quotation)
|
||
return res
|
||
|
||
async def get_last_supplier_type(self, supplier_id: str, company_id=None) -> Res_LastSupplierType:
|
||
"""협력사의 직전 견적 supplier_type(견적생성 모달 프리필용). 이력 없으면 비워서 반환."""
|
||
res = Res_LastSupplierType()
|
||
err_type, got = await DB_SESSION_MNG.execute_lambda(
|
||
quotations.DBType(),
|
||
DBWRType.DB_READ.value,
|
||
lambda s: self.quotation_crud.get_last_supplier_type(s, uuid.UUID(supplier_id), company_id),
|
||
)
|
||
if err_type != ErrorType.SUCCESS:
|
||
res.result.SetResult(err_type)
|
||
return res
|
||
if got:
|
||
res.supplier_type = got[0]
|
||
res.qt_number = got[1]
|
||
return res
|
||
|
||
async def create_quotation(self, user_id: str, req: Req_CreateQuotation) -> Res_CreateQuotation:
|
||
"""[프론트] 신규 견적 생성. 요청값을 보정한 뒤 공통 빌더(_build_quotation)에 위임한다."""
|
||
return await self._build_quotation(
|
||
user_id=user_id,
|
||
qt_setting_id=req.qt_setting_id,
|
||
version_id=req.version_id or self.DEFAULT_VERSION_ID,
|
||
name=req.name,
|
||
number=self._gen_number(), # 견적번호는 항상 서버 생성(프론트 입력란 없음)
|
||
type_=req.type,
|
||
status=req.status or QuotationStatus.CREATED.value,
|
||
round_=req.round or 1,
|
||
start_time=req.start_time or GTime.UTC(),
|
||
end_time=req.end_time,
|
||
manager_name=req.manager_name,
|
||
manager_email=req.manager_email,
|
||
manager_contact_number=req.manager_contact_number,
|
||
memo=req.memo,
|
||
md_price=req.md_price,
|
||
supplier_type=req.supplier_type,
|
||
item_ids=req.item_ids,
|
||
supplier_ids=req.supplier_ids,
|
||
card_ids=req.card_ids,
|
||
)
|
||
|
||
async def regenerate_next_round(self, original_qt_id: uuid.UUID, supplier_ids: list) -> Res_CreateQuotation:
|
||
"""[마감 후속] 결판 안 난 견적의 '다음 라운드'를 새로 만든다.
|
||
|
||
플로우:
|
||
1) 원 견적 + 세션을 조회해 대상 상품(item)을 복원
|
||
2) 타입 결정 — 다음 라운드 공급사가 1곳이면 재협상(RENEGO), 여러 곳이면 재견적(REQUOTE)
|
||
3) 같은 견적번호 + round+1 로 다음 라운드 생성 (협상기간은 원 견적과 같은 길이)
|
||
|
||
견적번호(number)를 원본 그대로 이어받아 '같은 번호 = 한 체인'으로 묶는다(parent_id 대체).
|
||
supplier_ids: 다음 라운드에 부를 공급사(동가면 동가 업체만, 그 외엔 원 견적 공급사 전체).
|
||
"""
|
||
res = Res_CreateQuotation()
|
||
|
||
# 1) 원 견적 + 세션 조회 → 대상 상품 복원
|
||
err_type, original = await self._fetch(original_qt_id)
|
||
if err_type != ErrorType.SUCCESS or original is None:
|
||
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, original_qt_id),
|
||
)
|
||
item_ids = list({r.item_id for r in rows}) if err_type == ErrorType.SUCCESS else []
|
||
# 재생성은 목표가/앵커링가를 재계산하지 않고 직전 라운드 세션 값을 그대로 상속(KTC 방식).
|
||
inherited = {r.item_id: (r.target_price, r.target_anchoring_price) for r in rows} if err_type == ErrorType.SUCCESS else {}
|
||
|
||
# 2) 타입 결정: 공급사 1곳 → 재협상 / 여러 곳 → 재견적
|
||
next_type = QuotationType.RENEGO.value if len(supplier_ids) <= 1 else QuotationType.REQUOTE.value
|
||
|
||
# 3) 다음 라운드의 견적 생성
|
||
now = GTime.UTC()
|
||
# 원본 협상기간을 이어쓰되, 비정상적으로 짧으면 최소 하한을 적용(즉시 만료→연쇄 재마감 방지).
|
||
duration = max(original.end_time - original.start_time, self.MIN_REGEN_DURATION)
|
||
# 다음 차수는 '원본 round+1' 이 아니라 '체인(같은 번호) 최신 round+1'.
|
||
# 크론 마감과 수동 regenerate_quotation 이 같은 체인을 처리하는 타이밍이 엇갈려도
|
||
# 항상 체인 끝에 이어붙어 uq_quotations_number(number, round) 충돌을 막는다.
|
||
_e, chain_max = await DB_SESSION_MNG.execute_lambda(
|
||
quotations.DBType(),
|
||
DBWRType.DB_READ.value,
|
||
lambda s: self.quotation_crud.chain_max_round(s, original.number),
|
||
)
|
||
base_round = chain_max if (_e == ErrorType.SUCCESS and chain_max) else original.round
|
||
next_round = base_round + 1
|
||
# 이름에 '(N차)' 표기. 원래 이름 기준(기존 '(M차)' 표기는 떼고 새로) + name 컬럼 50자 제한 보호.
|
||
suffix = f" ({next_round}차)"
|
||
base_name = re.sub(r"\s*\(\d+차\)\s*$", "", original.name or "")[: 50 - len(suffix)]
|
||
return await self._build_quotation(
|
||
user_id=str(original.user_id),
|
||
qt_setting_id=original.qt_setting_id,
|
||
version_id=original.version_id, # 카드 버전은 원본 그대로 이어씀
|
||
name=f"{base_name}{suffix}", # 예: "삼성 견적 (2차)"
|
||
number=original.number, # ← 원본 번호 따라감(새 번호 생성 X)
|
||
type_=next_type,
|
||
status=QuotationStatus.CREATED.value,
|
||
round_=next_round,
|
||
start_time=now,
|
||
end_time=now + duration,
|
||
manager_name=original.manager_name,
|
||
manager_email=original.manager_email,
|
||
manager_contact_number=original.manager_contact_number,
|
||
memo=original.memo,
|
||
md_price=original.md_price,
|
||
supplier_type=original.supplier_type,
|
||
item_ids=item_ids,
|
||
supplier_ids=list(supplier_ids),
|
||
card_ids=[], # 새 버전 안 만듦(원본 version_id 재사용)
|
||
inherited=inherited, # 직전 라운드 목표가·앵커링가 상속(재계산 안 함)
|
||
)
|
||
|
||
async def _build_quotation(
|
||
self, *,
|
||
user_id: str, qt_setting_id, version_id, name: str, number: str,
|
||
type_: int, status: int, round_: int, start_time, end_time,
|
||
manager_name, manager_email, manager_contact_number, memo, md_price, supplier_type,
|
||
item_ids: list, supplier_ids: list, card_ids: list,
|
||
inherited: Optional[dict] = None, # 재생성 시 {item_id: (target_price, target_anchoring_price)} 상속(KTC) — 있으면 재계산 안 함
|
||
) -> Res_CreateQuotation:
|
||
"""견적 1건 + (상품×공급사) 세션들을 한 트랜잭션으로 생성하는 공통 빌더."""
|
||
res = Res_CreateQuotation()
|
||
|
||
# 세션 목표가 입력(상품별 인터넷최저가/매입가/판매가 + 세팅 율). 읽기 트랜잭션에서 먼저 조회.
|
||
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, rates = await DB_SESSION_MNG.execute_lambda(
|
||
quotations.DBType(),
|
||
DBWRType.DB_READ.value,
|
||
lambda s: self.quotation_crud.get_setting_rates(s, qt_setting_id),
|
||
)
|
||
rates = rates if _err == ErrorType.SUCCESS else {}
|
||
fee = self.INTERNET_AVERAGE_FEE # 인터넷가 차감 수수료율(상수)
|
||
margin = rates.get("margin") or 0.0 # 판매가 차감 목표마진율
|
||
anchoring = rates.get("anchoring") or 0.0 # 앵커링가 = 목표가×(1−값)
|
||
|
||
# 선택 협상카드가 있으면 새 버전을 만들어 카드들을 묶고, quotation.version_id 로 연결한다.
|
||
# (quotation↔card 는 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=(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))
|
||
version_id = new_version_id
|
||
|
||
# qt_id 를 미리 발급해 세션 FK(quotation_id)와 묶고, 한 트랜잭션에 함께 insert 한다.
|
||
qt_id = uuid.uuid4()
|
||
quotation = quotations(
|
||
qt_id=qt_id,
|
||
user_id=uuid.UUID(user_id),
|
||
qt_setting_id=qt_setting_id,
|
||
version_id=version_id,
|
||
name=name,
|
||
number=number,
|
||
type=type_,
|
||
status=status,
|
||
round=round_,
|
||
start_time=start_time,
|
||
end_time=end_time,
|
||
manager_name=manager_name,
|
||
manager_email=manager_email,
|
||
manager_contact_number=manager_contact_number,
|
||
memo=memo,
|
||
md_price=md_price,
|
||
supplier_type=supplier_type,
|
||
)
|
||
|
||
# 상품 × 공급사 조합마다 세션 1개. md/매입/판매/인터넷 후보가 하나도 없으면 목표가 산정 불가 → 생성 실패.
|
||
# 신규(NEW_NEGO/NEW_QUOTE)는 인터넷최저가만, 재(RENEGO/REQUOTE)는 매입가·판매가까지 후보(KTC 신규/재 분리).
|
||
is_new = QuotationType.is_new(type_)
|
||
session_objs = []
|
||
try:
|
||
for iid in item_ids:
|
||
if inherited and iid in inherited:
|
||
tp, ap = inherited[iid] # 재생성: 직전 라운드 목표가·앵커링가 그대로 상속(KTC) — 재계산 안 함
|
||
else:
|
||
internet, purchase, selling = prices.get(iid) or (None, None, None)
|
||
tp = self._calc_target_price(md_price, internet, purchase, selling, fee, margin, is_new=is_new)
|
||
if not 0.0 <= anchoring < 1.0: # 율 1 이상이면 앵커링가가 0/음수 → 설정 오류로 막는다.
|
||
raise ValueError(f"앵커링 값은 0 이상 1 미만이어야 합니다: anchoring={anchoring}")
|
||
ap = int(tp * (1 - anchoring)) # 앵커링가 = floor(목표가×(1−앵커링율)); 율 0이면 목표가와 동일
|
||
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,
|
||
target_anchoring_price=ap,
|
||
status=SessionStatus.CREATED.value,
|
||
end_time=quotation.end_time,
|
||
)
|
||
)
|
||
except ValueError as ex:
|
||
LOG.w(
|
||
f"[목표가 산정불가] qt_id={qt_id} item={iid} is_new={is_new} "
|
||
f"md={md_price} internet={internet} purchase={purchase} selling={selling} :: {ex}"
|
||
)
|
||
res.result.SetResult(ErrorType.QUOTATION_TARGET_PRICE_UNAVAILABLE)
|
||
return res
|
||
|
||
# 버전 → (버전-카드 매핑) → 견적 → 세션 순으로 한 트랜잭션에 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
|
||
|
||
# 프론트는 생성 응답 본문을 화면에 안 쓰고 qt_id 로 재조회 → 새 id 와 세션 수만 반환.
|
||
res.qt_id = qt_id
|
||
res.session_count = len(session_objs)
|
||
return res
|
||
|
||
# ----- 마감 판정
|
||
@staticmethod
|
||
def _pick_winner(done_rows) -> tuple[Optional[dict], Optional[dict]]:
|
||
"""협상완료 세션들 중 낙찰자 판정. done_rows: [(supplier_id, bid_price, supplier_name), ...].
|
||
입찰가가 매겨진 세션 중 최저가가 단독이면 그 공급사를 낙찰로, 동가(둘+)면 낙찰은 비우고 동가 정보만 남긴다.
|
||
단독/동가는 상호배타. 반환: (winner|None, equal|None)."""
|
||
cands = [(sid, int(bp), name) for sid, bp, name in done_rows if bp is not None]
|
||
if not cands:
|
||
return None, None
|
||
min_price = min(c[1] for c in cands)
|
||
tied = [c for c in cands if c[1] == min_price]
|
||
if len(tied) > 1:
|
||
equal = {"price": min_price, "suppliers": [{"supplier_id": str(sid), "name": name} for sid, _, name in tied]}
|
||
return None, equal
|
||
return {"supplier_id": tied[0][0], "name": tied[0][2]}, None
|
||
|
||
async def _award_and_close(self, qt_uuid, winner) -> None:
|
||
"""단독 낙찰 확정 + 마감 + 미완료(미시작·진행중) 세션 미참여."""
|
||
data = {
|
||
"status": QuotationStatus.CLOSED.value,
|
||
"preferred_sp_yn": True,
|
||
"preferred_sp_id": winner["supplier_id"],
|
||
"preferred_sp_name": (winner["name"] or "")[:20],
|
||
"equal_bid_yn": False,
|
||
}
|
||
await DB_SESSION_MNG.execute_lambda_run(
|
||
[quotations.DBType()],
|
||
[
|
||
lambda s: self.quotation_crud.update_quotation(s, qt_uuid, data),
|
||
lambda s: self.quotation_crud.update_sessions_status(
|
||
s, qt_uuid, [SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value], SessionStatus.NOT_PARTICIPATED.value
|
||
),
|
||
],
|
||
)
|
||
|
||
async def _just_close(self, qt_uuid) -> None:
|
||
"""그냥 마감 + 미완료 세션 미참여."""
|
||
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
|
||
),
|
||
],
|
||
)
|
||
|
||
async def _close_as_no_show(self, qt_uuid) -> None:
|
||
"""전원 미참여로 '다음 라운드 재생성' 하며 마감 + 미완료 세션 미참여.
|
||
재생성 사유(미참여)를 체인에 남기기 위해 preferred_sp_yn=False, equal_bid_yn=False 로 양성 표식한다
|
||
(단독낙찰=preferred_sp_yn True / 동가=equal_bid_yn True / 거부·한도 등 그냥 마감=둘 다 NULL 과 구분).
|
||
_chain_regen_counts 가 이 표식으로 '미참여 재생성 이력'만 정확히 센다."""
|
||
data = {
|
||
"status": QuotationStatus.CLOSED.value,
|
||
"preferred_sp_yn": False,
|
||
"equal_bid_yn": False,
|
||
}
|
||
await DB_SESSION_MNG.execute_lambda_run(
|
||
[quotations.DBType()],
|
||
[
|
||
lambda s: self.quotation_crud.update_quotation(s, qt_uuid, data),
|
||
lambda s: self.quotation_crud.update_sessions_status(
|
||
s, qt_uuid, [SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value], SessionStatus.NOT_PARTICIPATED.value
|
||
),
|
||
],
|
||
)
|
||
|
||
async def _close_as_equal(self, qt_uuid, equal) -> None:
|
||
"""동가로 마감 + 미완료 세션 미참여. equal_bid_yn/data 를 기록해 둔다
|
||
(재생성 한도 계산이 이 플래그로 동가 라운드를 식별하고, 프론트도 동가 정보를 그대로 쓴다)."""
|
||
data = {
|
||
"status": QuotationStatus.CLOSED.value,
|
||
"preferred_sp_yn": False,
|
||
"equal_bid_yn": True,
|
||
"equal_bid_data": equal,
|
||
}
|
||
await DB_SESSION_MNG.execute_lambda_run(
|
||
[quotations.DBType()],
|
||
[
|
||
lambda s: self.quotation_crud.update_quotation(s, qt_uuid, data),
|
||
lambda s: self.quotation_crud.update_sessions_status(
|
||
s, qt_uuid, [SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value], SessionStatus.NOT_PARTICIPATED.value
|
||
),
|
||
],
|
||
)
|
||
|
||
async def close_and_decide(self, qt_id) -> CloseOutcome:
|
||
"""[마감] 견적을 마감하면서 결과를 판정한다.
|
||
1) 협상완료 중 최저가 단독 → 그 공급사 낙찰 확정
|
||
2) 협상완료 중 최저가 동가 → 다음 라운드 재생성(동가 업체끼리) [체인에 동가 재생성 이력 없을 때만]
|
||
3) 협상거부 세션이 하나라도 있음 → 그냥 마감 (재생성 안 함)
|
||
4) 전원 미참여(완료·거부 0) → 다음 라운드 재생성(원 견적 공급사 전체) [체인에 미참여 재생성 이력 없을 때만]
|
||
5) 그 외 / 한도 도달 → 그냥 마감
|
||
동가를 거부보다 먼저 본다: 동률은 '협상완료'한 업체들 간 경쟁이라 무관한 다른 업체의 거부로 막지 않는다.
|
||
재생성 한도: 한 체인(같은 견적번호)에서 '미참여' 1번 + '동가' 1번(순서 무관, 같은 사유 2번은 불가).
|
||
공통: status→CLOSED, 미시작·진행중 세션→미참여."""
|
||
qt_uuid = qt_id if isinstance(qt_id, uuid.UUID) else uuid.UUID(str(qt_id))
|
||
|
||
err_type, original = await self._fetch(qt_uuid)
|
||
if err_type != ErrorType.SUCCESS or original is None:
|
||
return CloseOutcome.CLOSED
|
||
|
||
# [동시 마감 가드] 마감 판정 전에 원자적으로 status→CLOSED 를 선점한다.
|
||
# 두 크론 잡(close_expired / close_negotiated)이나 수동 stop_quotation 이 같은 견적을
|
||
# 동시에 닫으려 해도, 실제로 CLOSED 로 전이한 호출자만 통과하고 진 호출자는 여기서 끝난다
|
||
# → 이중 재생성·uq(number,round) 충돌 방지. (이미 닫힌 견적의 재처리도 여기서 차단)
|
||
claim_err, claimed = await DB_SESSION_MNG.execute_lambda_claim(
|
||
quotations.DBType(),
|
||
lambda s: self.quotation_crud.claim_for_close(s, qt_uuid),
|
||
)
|
||
if claim_err != ErrorType.SUCCESS or claimed == 0:
|
||
return CloseOutcome.CLOSED
|
||
|
||
err_type, rows = await DB_SESSION_MNG.execute_lambda(
|
||
sessions.DBType(),
|
||
DBWRType.DB_READ.value,
|
||
lambda s: self.quotation_crud.list_sessions_status(s, qt_uuid),
|
||
)
|
||
rows = rows if err_type == ErrorType.SUCCESS else []
|
||
|
||
done = [(r.supplier_id, r.bid_price, r.name) for r in rows if r.status == SessionStatus.DONE.value]
|
||
has_rejected = any(r.status == SessionStatus.REJECTED.value for r in rows)
|
||
winner, equal = self._pick_winner(done)
|
||
|
||
# 1) 단독 낙찰 → 확정
|
||
if winner is not None:
|
||
await self._award_and_close(qt_uuid, winner)
|
||
winner_price = min((int(bp) for _, bp, _ in done if bp is not None), default=None)
|
||
await create_notification(
|
||
original.user_id, NotificationType.SUCCESS,
|
||
{"qt_name": original.name, "qt_number": original.number,
|
||
"winner_name": winner["name"], "winner_price": winner_price},
|
||
ref_qt_id=qt_uuid,
|
||
)
|
||
return CloseOutcome.AWARDED
|
||
|
||
# 동가/미참여 재생성은 사유별 한도(각 1번, 순서 무관) 확인 후
|
||
no_part_used, equal_used = await self._chain_regen_counts(original.number, original.round)
|
||
|
||
# 2) 동가 → 동가 업체끼리 다음 라운드 (거부보다 먼저: tie 해소 우선, 체인에 동가 이력 없을 때만)
|
||
if equal is not None and equal_used < self.MAX_REGEN_PER_CAUSE:
|
||
tied_ids = [uuid.UUID(sp["supplier_id"]) for sp in equal["suppliers"]]
|
||
await self._close_as_equal(qt_uuid, equal) # 동가 기록(equal_bid_yn) 후 마감
|
||
regen = await self.regenerate_next_round(qt_uuid, tied_ids)
|
||
if not regen.result.success:
|
||
# 원본은 이미 CLOSED 인데 다음 라운드 생성이 실패 → 체인이 끊긴 상태. 성공으로 위장하지 않고 드러낸다.
|
||
LOG.e_no_callstack(
|
||
f"[close] 동가 재생성 실패 qt={qt_uuid} number={original.number} round={original.round} "
|
||
f"code={regen.result.code}({regen.result.desc})"
|
||
)
|
||
return CloseOutcome.REGEN_FAILED
|
||
await create_notification(
|
||
original.user_id, NotificationType.REGENERATED,
|
||
{"qt_name": original.name, "qt_number": original.number, "reason": "equal",
|
||
"next_round": original.round + 1, "tied_price": equal["price"], "tied_count": len(equal["suppliers"])},
|
||
ref_qt_id=regen.qt_id,
|
||
)
|
||
return CloseOutcome.REGENERATED
|
||
# 3) 협상거부 있음 → 마감만 (재생성 안 함)
|
||
if has_rejected:
|
||
await self._just_close(qt_uuid)
|
||
await create_notification(
|
||
original.user_id, NotificationType.FAILURE,
|
||
{"qt_name": original.name, "qt_number": original.number, "reason": "rejected"},
|
||
ref_qt_id=qt_uuid,
|
||
)
|
||
return CloseOutcome.CLOSED
|
||
# 4) 전원 미참여 → 공급사 전체로 다음 라운드 (체인에 미참여 재생성 이력 없을 때만)
|
||
if not done and rows and no_part_used < self.MAX_REGEN_PER_CAUSE:
|
||
supplier_ids = list({r.supplier_id for r in rows})
|
||
await self._close_as_no_show(qt_uuid) # 미참여 재생성 표식(preferred_sp_yn=False, equal_bid_yn=False) 후 마감
|
||
regen = await self.regenerate_next_round(qt_uuid, supplier_ids)
|
||
if not regen.result.success:
|
||
# 원본은 이미 CLOSED 인데 다음 라운드 생성이 실패 → 체인이 끊긴 상태. 성공으로 위장하지 않고 드러낸다.
|
||
LOG.e_no_callstack(
|
||
f"[close] 미참여 재생성 실패 qt={qt_uuid} number={original.number} round={original.round} "
|
||
f"code={regen.result.code}({regen.result.desc})"
|
||
)
|
||
return CloseOutcome.REGEN_FAILED
|
||
await create_notification(
|
||
original.user_id, NotificationType.REGENERATED,
|
||
{"qt_name": original.name, "qt_number": original.number, "reason": "no_show", "next_round": original.round + 1},
|
||
ref_qt_id=regen.qt_id,
|
||
)
|
||
return CloseOutcome.REGENERATED
|
||
# 5) 그 외 / 한도 도달 → 마감만
|
||
await self._just_close(qt_uuid)
|
||
await create_notification(
|
||
original.user_id, NotificationType.FAILURE,
|
||
{"qt_name": original.name, "qt_number": original.number, "reason": "closed"},
|
||
ref_qt_id=qt_uuid,
|
||
)
|
||
return CloseOutcome.CLOSED
|
||
|
||
async def _chain_regen_counts(self, number: str, current_round: int) -> tuple[int, int]:
|
||
"""체인(같은 견적번호) 이전 라운드들의 '재생성 사유' 횟수. 반환: (미참여 횟수, 동가 횟수).
|
||
마감 시 남긴 양성 표식으로만 센다(오집계 방지):
|
||
- 동가 재생성 → equal_bid_yn=True
|
||
- 미참여 재생성 → preferred_sp_yn=False AND equal_bid_yn=False
|
||
단독낙찰(preferred_sp_yn=True)·거부/한도 그냥 마감(둘 다 NULL)은 어느 쪽에도 세지 않는다.
|
||
(수동 regenerate_quotation 으로 단독낙찰·거부 라운드를 이어붙여도 자동 재생성 한도에 영향 없음.)"""
|
||
err_type, flags = await DB_SESSION_MNG.execute_lambda(
|
||
quotations.DBType(),
|
||
DBWRType.DB_READ.value,
|
||
lambda s: self.quotation_crud.list_chain_close_flags(s, number, current_round),
|
||
)
|
||
if err_type != ErrorType.SUCCESS:
|
||
return 0, 0
|
||
equal = sum(1 for _pref, eq in flags if eq is True)
|
||
no_part = sum(1 for pref, eq in flags if pref is False and eq is False)
|
||
return no_part, equal
|
||
|
||
async def regenerate_quotation(self, qt_id: str, company_id, supplier_ids: list) -> Res_CreateQuotation:
|
||
"""[프론트] 마감된 견적을 골라 수동으로 다음 라운드를 생성한다.
|
||
크론/수동마감의 자동 재생성과 달리 사유·체인 한도 판정 없이, 프론트가 고른 공급사로 바로 만든다.
|
||
상품·기간·견적번호·카드버전은 원 견적에서 이어받는다(regenerate_next_round)."""
|
||
res = Res_CreateQuotation()
|
||
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
|
||
# 마감된 견적만 재생성(진행 중인 라운드를 또 찍어 같은 번호가 동시에 살아있는 걸 막는다).
|
||
if original.status != QuotationStatus.CLOSED.value:
|
||
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
|
||
return res
|
||
# 공급사 미선택이면 세션이 0건이라 의미 없음.
|
||
if not supplier_ids:
|
||
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
|
||
return res
|
||
# 마지막 차수에서만 재생성 — 옛 라운드/뒤 라운드 살아있는데 또 생성하는 걸 막고(uq(number,round) 충돌도 예방),
|
||
# 마지막이 아니면 명시적 에러를 던진다.
|
||
_e, max_round = await DB_SESSION_MNG.execute_lambda(
|
||
quotations.DBType(),
|
||
DBWRType.DB_READ.value,
|
||
lambda s: self.quotation_crud.chain_max_round(s, original.number),
|
||
)
|
||
if _e == ErrorType.SUCCESS and max_round and original.round < max_round:
|
||
res.result.SetResult(ErrorType.QUOTATION_NOT_LATEST_ROUND)
|
||
res.msg = "마지막 차수의 견적에서만 다음 라운드를 생성할 수 있습니다."
|
||
return res
|
||
|
||
return await self.regenerate_next_round(qt_uuid, supplier_ids)
|
||
|
||
async def stop_quotation(self, qt_id: str, company_id=None) -> Res_Quotation:
|
||
"""[프론트] 수동 견적마감. 크론과 똑같은 마감 판정(close_and_decide)을 탄다
|
||
(단독낙찰 확정 / 동가·미참여면 다음 라운드 재생성 / 거부·한도면 그냥 마감)."""
|
||
res = Res_Quotation()
|
||
qt_uuid = uuid.UUID(qt_id)
|
||
|
||
# 존재 확인(+회사 가드)
|
||
err_type, _ = await self._fetch(qt_uuid, company_id)
|
||
if err_type != ErrorType.SUCCESS:
|
||
res.result.SetResult(err_type)
|
||
return res
|
||
|
||
await self.close_and_decide(qt_uuid)
|
||
return await self.get_quotation(qt_id, company_id)
|
||
|
||
async def delete_quotation(self, qt_id: str, company_id=None) -> Res_DeleteQuotation:
|
||
res = Res_DeleteQuotation()
|
||
qt_uuid = uuid.UUID(qt_id)
|
||
|
||
err_type, _ = await self._fetch(qt_uuid, company_id)
|
||
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, company_id=None) -> Res_QuotationStatus:
|
||
res = Res_QuotationStatus()
|
||
err_type, quotation = await self._fetch(uuid.UUID(qt_id), company_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, company_id=None) -> Res_QuotationResult:
|
||
res = Res_QuotationResult()
|
||
err_type, quotation = await self._fetch(uuid.UUID(qt_id), company_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, company_id=None) -> Res_QuotationSessions:
|
||
res = Res_QuotationSessions()
|
||
qt_uuid = uuid.UUID(qt_id)
|
||
err_type, quotation = await self._fetch(qt_uuid, company_id)
|
||
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,
|
||
target_anchoring_price=r.target_anchoring_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,
|
||
email_sent_at=r.email_sent_at,
|
||
url=self._session_chat_url(r.session_id),
|
||
)
|
||
for r in rows
|
||
]
|
||
res.total = len(res.sessions)
|
||
return res
|
||
|
||
# ----- 협상 초청 메일 (수동 발송)
|
||
async def notify_sessions(self, qt_id: str, company_id=None) -> Res_NotifySessions:
|
||
"""[수동 발송] 견적의 '미발송' 세션(공급사 담당자)에게 협상 초청 메일을 일괄 발송한다.
|
||
대상 = email_sent_at IS NULL + 담당자 이메일 보유."""
|
||
res = Res_NotifySessions()
|
||
qt_uuid = uuid.UUID(qt_id)
|
||
err_type, quotation = await self._fetch(qt_uuid, company_id)
|
||
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_with_supplier(s, qt_uuid),
|
||
)
|
||
if err_type != ErrorType.SUCCESS:
|
||
res.result.SetResult(err_type)
|
||
return res
|
||
|
||
# 행 언팩: (session, supplier_name, manager_email).
|
||
targets = [] # [(session, name, email)]
|
||
for r in rows:
|
||
sess, sp_name, email = r[0], r[1], r[2]
|
||
res.total += 1
|
||
if sess.email_sent_at is not None:
|
||
continue # 이미 발송됨 — 재발송은 행 단위 endpoint 로
|
||
if not email:
|
||
res.skipped += 1
|
||
continue
|
||
targets.append((sess, sp_name, email))
|
||
|
||
sent_ids = await self._send_invites(quotation, targets, res)
|
||
if sent_ids:
|
||
await self._mark_emailed(sent_ids)
|
||
return res
|
||
|
||
async def notify_session(self, session_id: str, company_id=None) -> Res_NotifySessions:
|
||
"""[수동 재발송] 단일 세션(공급사)에 초청 메일 발송(이미 보냈어도 강제 재발송)."""
|
||
res = Res_NotifySessions()
|
||
sess_uuid = uuid.UUID(session_id)
|
||
err_type, 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 err_type != ErrorType.SUCCESS or got is None:
|
||
res.result.SetResult(err_type if err_type != ErrorType.SUCCESS else ErrorType.QUOTATION_NOT_FOUND)
|
||
return res
|
||
sess, sp_name, email = got[0], got[1], got[2]
|
||
res.total = 1
|
||
err_type, quotation = await self._fetch(sess.quotation_id, company_id)
|
||
if err_type != ErrorType.SUCCESS:
|
||
res.result.SetResult(err_type)
|
||
return res
|
||
if not email:
|
||
res.skipped = 1
|
||
return res
|
||
|
||
sent_ids = await self._send_invites(quotation, [(sess, sp_name, email)], res)
|
||
if sent_ids:
|
||
await self._mark_emailed(sent_ids)
|
||
return res
|
||
|
||
async def _send_invites(self, quotation, targets: list, res: Res_NotifySessions) -> list:
|
||
"""targets [(session, supplier_name, email)] 에 초청 메일 발송. res.sent/failed 를 채우고
|
||
성공한 session_id 목록을 반환. ACS/SMTP 미설정이면 첫 발송에서 중단(EMAIL_NOT_CONFIGURED)."""
|
||
sent_ids = []
|
||
for sess, sp_name, email in targets:
|
||
subject, html, text = build_invite_email(
|
||
supplier_name=sp_name or "",
|
||
quotation_name=quotation.name,
|
||
qt_number=quotation.number,
|
||
end_time=quotation.end_time,
|
||
chat_url=self._session_chat_url(sess.session_id),
|
||
)
|
||
try:
|
||
await send_email(email, subject, html, text)
|
||
sent_ids.append(sess.session_id)
|
||
res.sent += 1
|
||
except EmailUnavailable as e:
|
||
res.result.SetResult(ErrorType.EMAIL_NOT_CONFIGURED) # 발송 채널 없음 — 더 시도해도 무의미
|
||
res.msg = str(e)
|
||
break
|
||
except Exception as ex:
|
||
LOG.e_no_callstack(ex)
|
||
res.failed += 1
|
||
# 보낼 대상이 있었는데 전부 실패면 명시적 실패 코드(설정은 됐으나 발송 실패).
|
||
if res.sent == 0 and res.failed > 0 and res.result.success:
|
||
res.result.SetResult(ErrorType.EMAIL_SEND_FAILED)
|
||
return sent_ids
|
||
|
||
async def _mark_emailed(self, session_ids: list) -> None:
|
||
"""발송 성공 세션들의 email_sent_at 갱신(write 트랜잭션)."""
|
||
now = GTime.UTC()
|
||
await DB_SESSION_MNG.execute_lambda_run(
|
||
[sessions.DBType()],
|
||
[lambda s: self.quotation_crud.mark_sessions_emailed(s, session_ids, now)],
|
||
)
|
||
|
||
async def list_chats(self, session_id: str, company_id=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)
|
||
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) -> Res_QuotationCards:
|
||
res = Res_QuotationCards()
|
||
qt_uuid = uuid.UUID(qt_id)
|
||
err_type, quotation = await self._fetch(qt_uuid, company_id)
|
||
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
|