342 lines
19 KiB
Python
342 lines
19 KiB
Python
"""견적 생성·재생성 — 공통 빌더(_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
|