o2o-negosium-original/negodata/backend/services/quotation_service.py
hbyang 7e0f88ca03 [fix] 견적 자동마감 동시성·정합성 + 프론트 안정화 (코드리뷰 후속)
백엔드 close_and_decide 경로:
- 동시 이중 마감 가드: 마감 판정 전 원자적 CLOSED 선점(claim)으로 두 크론 잡·수동마감 경합 직렬화
- 재생성 실패 표면화: regenerate_next_round 결과 검사 → 실패 시 REGEN_FAILED 반환(체인 끊김 은폐 방지)
- 차수 충돌 방지: 다음 라운드 = 체인 최신 round+1(chain_max_round 기준)
- 재생성 사유 집계 정밀화: 미참여/동가를 양성 표식으로 구분(단독낙찰·거부 오집계 제거)
- 재생성 라운드 최소 협상기간 하한(즉시 재마감 캐스케이드 방지)
- 잡 루프 per-item 예외 격리(한 건 실패가 배치 전체를 멈추지 않음)

프론트:
- useChatController: 무권한 가드를 sessionId 별로 추적해 세션 변경 시 자연 해제
- useScrollLock: 마지막 해제를 rAF 로 지연해 재마운트 사이 일시적 잠금 해제 방지
- quotation 상세 쿼리 placeholderData 로 라운드 전환 중 시트 유지

테스트:
- 신규 test_close_and_decide_fixes.py(동시성·차수·집계·기간 하한 검증)
- conftest 결함 수정(존재하지 않는 tbl_account TRUNCATE 제거, companies.status 명시)
- stale 테스트 갱신(test_quotation_create 를 타입드 Req/신규 응답 형식에 맞게 재작성)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 17:22:21 +09:00

736 lines
35 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 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, 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_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")
# 재생성 한도: 한 체인(같은 견적번호)에서 사유(미참여/동가)별 최대 1번까지 재생성(순서 무관, 같은 사유 2번 불가).
MAX_REGEN_PER_CAUSE = 1
# 재생성 라운드의 최소 협상기간(방어적 하한). 원본 협상기간이 비정상적으로 짧으면(또는 0/음수면)
# 새 라운드가 생성 즉시 만료돼 다음 크론 tick(*/5분)에 또 마감되는 연쇄를 막는다.
# 정상 견적(수 시간~수일)은 원본 기간을 그대로 쓰며, 이 하한은 비정상적으로 짧은 경우에만 적용된다.
# TODO 하한값 변경 해야함 !!! feat. MarineYang
MIN_REGEN_DURATION = timedelta(hours=1)
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, 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=self._naive_utc(req.start_time or GTime.UTC()),
end_time=self._naive_utc(req.end_time),
manager_name=req.manager_name,
manager_email=req.manager_email,
manager_contact_number=req.manager_contact_number,
memo=req.memo,
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 []
# 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,
item_ids=item_ids,
supplier_ids=list(supplier_ids),
card_ids=[], # 새 버전 안 만듦(원본 version_id 재사용)
)
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,
item_ids: list, supplier_ids: list, card_ids: list,
) -> 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, margin = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(),
DBWRType.DB_READ.value,
lambda s: self.quotation_crud.get_target_margin(s, qt_setting_id),
)
margin = margin if _err == ErrorType.SUCCESS else None
# 선택 협상카드가 있으면 새 버전을 만들어 카드들을 묶고, 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,
)
# 상품 × 공급사 조합마다 세션 1개.
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
# 프론트는 생성 응답 본문을 화면에 안 쓰고 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)
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
return CloseOutcome.REGENERATED
# 3) 협상거부 있음 → 마감만 (재생성 안 함)
if has_rejected:
await self._just_close(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
return CloseOutcome.REGENERATED
# 5) 그 외 / 한도 도달 → 마감만
await self._just_close(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, 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)
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) -> Res_Quotation:
"""[프론트] 수동 견적마감. 크론과 똑같은 마감 판정(close_and_decide)을 탄다
(단독낙찰 확정 / 동가·미참여면 다음 라운드 재생성 / 거부·한도면 그냥 마감)."""
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
await self.close_and_decide(qt_uuid)
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