o2o-negosium-original/negodata/backend/services/quotation/build.py

342 lines
19 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.

"""견적 생성·재생성 — 공통 빌더(_build_quotation)와 진입점들."""
import re
import uuid
from datetime import timedelta
from typing import Optional
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, versions, version_nego_cards, version_wild_cards
from common.enums import DBWRType, ErrorType, NotificationType, PriceGateAction, QuotationStatus, QuotationType, SessionStatus
from common.utils.gtime import GTime
from router.v1.quotation.protocol import Req_CreateQuotation, Res_CreateQuotation
from services.notification import create_notification
class BuildMixin:
# 기본 전략 버전(card.versions 시드). 견적 생성 시 version_id 미지정이면 이 값으로 채운다.
DEFAULT_VERSION_ID = uuid.UUID("00000000-0000-0000-0000-000000000030")
# 재생성 라운드의 최소 협상기간(방어적 하한). 원본 협상기간이 비정상적으로 짧으면(또는 0/음수면)
# 새 라운드가 생성 즉시 만료돼 다음 크론 tick(*/5분)에 또 마감되는 연쇄를 막는다.
# 정상 견적(수 시간~수일)은 원본 기간을 그대로 쓰며, 이 하한은 비정상적으로 짧은 경우에만 적용된다.
# TODO 하한값 변경 해야함 !!! feat. MarineYang
MIN_REGEN_DURATION = timedelta(hours=1)
@staticmethod
def _gen_number() -> str:
"""견적번호 자동 생성(미지정 시). EST-YYYYMM-XXXX."""
now = GTime.UTC()
return f"EST-{now:%Y%m}-{uuid.uuid4().hex[:4].upper()}"
async def create_quotation(self, user_id: str, req: Req_CreateQuotation) -> Res_CreateQuotation:
"""[프론트] 신규 견적 생성. 요청값을 보정한 뒤 공통 빌더(_build_quotation)에 위임한다.
생성 성공 시 작성자에게 CREATED 알림(인박스)."""
number = self._gen_number() # 견적번호는 항상 서버 생성(프론트 입력란 없음)
res = 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=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,
item_ids=req.item_ids,
supplier_ids=req.supplier_ids,
card_ids=req.card_ids or None, # 미선택이면 새 버전 없이 기본 전략 버전(version_id)을 그대로 쓴다
mid_action=req.mid_action,
over_action=req.over_action,
done_ceiling_rate=req.done_ceiling_rate,
)
if res.result.success:
await create_notification(
user_id, NotificationType.CREATED,
{"qt_name": req.name, "qt_number": number},
ref_qt_id=res.qt_id,
)
return res
async def regenerate_next_round(
self, original_qt_id: uuid.UUID, supplier_ids: list, regen_label: Optional[str] = None, *,
card_ids: Optional[list] = None, target_price: Optional[int] = None,
end_time=None, done_ceiling_rate: Optional[int] = None,
) -> Res_CreateQuotation:
"""[재생성] 마감된 견적의 '다음 라운드'를 새로 만든다. 호출 경로는 수동 재생성·재협상 승인뿐.
플로우:
1) 원 견적 + 세션을 조회해 대상 상품(item)을 복원
2) 타입 결정 — 다음 라운드 공급사가 1곳이면 재협상(RENEGO), 여러 곳이면 재견적(REQUOTE)
3) 같은 견적번호 + round+1 로 다음 라운드 생성 (협상기간은 원 견적과 같은 길이)
견적번호(number)를 원본 그대로 이어받아 '같은 번호 = 한 체인'으로 묶는다(parent_id 대체).
supplier_ids: 다음 라운드에 부를 공급사(동가면 동가 업체만, 그 외엔 원 견적 공급사 전체).
card_ids·target_price·end_time·done_ceiling_rate 는 담당자가 이번 라운드에서만 바꾸는 조정값이다.
미지정(None)이면 전부 원 견적/직전 라운드 값을 그대로 승계한다(기존 동작).
"""
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 방식).
# 담당자가 목표가를 다시 잡았으면(target_price) 그 값이 이번 라운드 전 상품의 목표가가 된다.
# 앵커링가는 어느 쪽이든 상속하지 않는다 — 생성 시점의 칸 rate 로 항상 재계산·박제(앵커링 v1.2 인수인계 규칙 1).
if target_price is not None:
inherited_target_prices = {iid: target_price for iid in item_ids}
else:
inherited_target_prices = {r.item_id: r.target_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)
next_end_time = end_time or (now + 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차)'.
# 원래 이름의 기존 접미사(차수/재협상)는 떼고 새로 붙인다 + name 컬럼 50자 제한 보호.
suffix = f" ({regen_label})" if regen_label else 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=next_end_time,
manager_name=original.manager_name,
manager_email=original.manager_email,
manager_contact_number=original.manager_contact_number,
memo=original.memo,
md_price=target_price if target_price is not None else original.md_price,
item_ids=item_ids,
supplier_ids=list(supplier_ids),
# None=원본 version_id 재사용(새 버전 안 만듦), 리스트=이 카드들로 새 버전 생성(빈 리스트면 카드 없는 버전).
card_ids=card_ids,
mid_action=original.mid_action, # 낙찰 기준 상속(타입이 REQUOTE 로 바뀌면 빌더가 AWARD 로 재정규화)
over_action=original.over_action,
done_ceiling_rate=done_ceiling_rate if done_ceiling_rate is not None else original.done_ceiling_rate,
inherited_target_prices=inherited_target_prices, # 직전 라운드 목표가 상속(앵커링가는 현재 rate 로 재계산)
)
async def regenerate_quotation(
self, qt_id: str, company_id, supplier_ids: list, user_id=None, role=None, regen_label: Optional[str] = None, *,
card_ids: Optional[list] = None, target_price: Optional[int] = None,
end_time=None, done_ceiling_rate: Optional[int] = None,
) -> 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
# 소유자 게이팅 — 본인 견적 또는 최고관리자만 재생성(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
# 마감된 견적만 재생성(진행 중인 라운드를 또 찍어 같은 번호가 동시에 살아있는 걸 막는다).
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, regen_label=regen_label,
card_ids=card_ids, target_price=target_price,
end_time=end_time, done_ceiling_rate=done_ceiling_rate,
)
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,
item_ids: list, supplier_ids: list,
# None = 넘겨받은 version_id 를 그대로 쓴다(카드 승계). 리스트면 이 카드들로 새 버전을 만든다(빈 리스트=카드 없는 버전).
card_ids: Optional[list],
mid_action: Optional[int] = None, # 낙찰 기준(견적 단위). 앵커링가<투찰가≤목표가 처리(AWARD/OPEN)
over_action: Optional[int] = None, # 목표가<투찰가 처리(1:1 협상은 항상 OPEN)
done_ceiling_rate: Optional[int] = None, # 협상 완료 상한율(‰) 견적 override. None 이면 세팅 기본값
inherited_target_prices: Optional[dict] = None, # 재생성 시 직전 라운드 목표가 상속(KTC). 목표가만 — 앵커는 항상 재계산
) -> Res_CreateQuotation:
"""견적 1건 + (상품×공급사) 세션들을 한 트랜잭션으로 생성하는 공통 빌더."""
res = Res_CreateQuotation()
# 낙찰 기준 정규화 — 1:N 경매는 항상 최저가 낙찰(mid=over=AWARD 강제). 1:1 협상은 요청값(미지정=AWARD).
# create/regenerate 양 경로가 이 빌더를 타므로 불변식을 여기 한 곳에서 강제한다(재생성 시 타입 전환도 자동 재정규화).
if QuotationType.is_auction(type_):
mid_action = over_action = PriceGateAction.AWARD.value
else:
mid_action = mid_action or PriceGateAction.AWARD.value
over_action = over_action or PriceGateAction.AWARD.value
# 목표가 계산 재료(가격·비율·회사 설정)를 먼저 모아온다.
prices, fee, margin, hidden, setting_ceiling_rate = await self._load_target_inputs(item_ids, qt_setting_id, user_id)
# 완료 상한율(‰) — 견적 override 우선, 없으면 세팅 기본. 세션에 완료 상한가(원)로 박제한다.
effective_ceiling_rate = done_ceiling_rate if done_ceiling_rate is not None else setting_ceiling_rate
# 선택 협상카드가 있으면 새 버전을 만들어 카드들을 묶고, quotation.version_id 로 연결한다.
# (quotation↔card 는 version → version_nego_cards/version_wild_cards 로 연결.)
version_obj = None
link_rows = []
if card_ids is not None:
_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,
mid_action=mid_action,
over_action=over_action,
done_ceiling_rate=done_ceiling_rate, # 견적 override 원본 저장(None=세팅 따름)
)
# 상품 × 공급사 조합마다 세션 1개. md/매입/판매/인터넷 후보가 하나도 없으면 목표가 산정 불가 → 생성 실패.
# 신규(NEW_NEGO/NEW_QUOTE)는 인터넷최저가만, 재(RENEGO/REQUOTE)는 매입가·판매가까지 후보(KTC 신규/재 분리).
is_new = QuotationType.is_new(type_)
# 상품별 목표가 결정
try:
target_prices = self._resolve_target_prices(
qt_id=qt_id, item_ids=item_ids, prices=prices,
md_price=md_price, fee=fee, margin=margin,
is_new=is_new, hidden=hidden, inherited_target_prices=inherited_target_prices,
)
except ValueError:
res.result.SetResult(ErrorType.QUOTATION_TARGET_PRICE_UNAVAILABLE)
return res
# 앵커가 산출 — 칸별 조정값 조회 후 정수 연산으로 박제(상세는 _resolve_anchors).
anchors = await self._resolve_anchors(item_ids, supplier_ids, target_prices)
session_objs = []
for iid in item_ids:
tp = target_prices[iid]
# 완료 상한가 = 목표가×(1+상한율/1000), 10원 반올림(앵커가와 동일한 정수 연산). 상한율 없으면 목표가로 폴백.
ceiling_price = (
int((tp * (1000 + effective_ceiling_rate) + 5000) // 10000) * 10
if effective_ceiling_rate is not None else tp
)
for sid in supplier_ids:
value, ap = anchors[(iid, sid)]
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,
anchoring_price=ap, # 박제 — 이후 수정 금지(협상 판정·앵커링 학습 기준값)
anchoring_value=value,
done_ceiling_price=ceiling_price, # 박제 — 봇 종결·마감이 이 이하면 타결
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