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