import uuid from datetime import timezone 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 DBWRType, ErrorType, QuotationStatus, SessionStatus 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 ( AsyncJob, ChatMessageData, QuotationCardData, QuotationData, SessionData, 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") 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, data: dict) -> Res_CreateQuotation: res = Res_CreateQuotation() # 세션 생성용 입력은 quotations 컬럼이 아니므로 분리한다(상품 × 공급사 조합마다 세션 1개). item_ids = data.pop("item_ids", []) or [] supplier_ids = data.pop("supplier_ids", []) or [] card_ids = data.pop("card_ids", []) or [] # NOT NULL 컬럼 보정(프론트 미전송 시 서버 디폴트). if not data.get("version_id"): data["version_id"] = self.DEFAULT_VERSION_ID if not data.get("start_time"): data["start_time"] = GTime.UTC() if not data.get("number"): data["number"] = self._gen_number() if not data.get("status"): data["status"] = QuotationStatus.CREATED.value if not data.get("round"): data["round"] = 1 # ORM 기본값은 flush 시점이라, 세션 스냅샷용으로 미리 확정한다 # DB 컬럼이 naive 라, tz-aware 로 들어온 시각(프론트 toISOString)을 UTC naive 로 맞춘다. data["start_time"] = self._naive_utc(data.get("start_time")) data["end_time"] = self._naive_utc(data.get("end_time")) # 세션 목표가 입력(상품 단가 + 견적 세팅 목표 마진율). 읽기 트랜잭션에서 먼저 조회. 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, data["qt_setting_id"]), ) margin = margin if _err == ErrorType.SUCCESS else None # 선택 협상카드가 있으면 새 버전을 만들어 카드들을 묶고, quotation.version_id 로 연결한다. # (quotation↔card 는 chats 가 아니라 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=(data.get("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)) data["version_id"] = new_version_id # qt_id 를 미리 발급해 세션 FK(quotation_id)와 묶고, 한 트랜잭션에 함께 insert 한다. qt_id = uuid.uuid4() quotation = quotations(**data, qt_id=qt_id, user_id=uuid.UUID(user_id)) 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 # 서버 기본값(created_at/updated_at) 로드 위해 재조회. f_err, fresh = await DB_SESSION_MNG.execute_lambda( quotations.DBType(), DBWRType.DB_READ.value, lambda s: self.quotation_crud.get_by_id(s, qt_id), ) res.quotation = QuotationData.model_validate(fresh if f_err == ErrorType.SUCCESS and fresh is not None else quotation) # 생성된 세션 + 각 세션 chat 실행 URL 을 함께 반환(협상리스트에서 바로 진입 가능). res.sessions = [ SessionData( session_id=so.session_id, qt_id=so.quotation_id, supplier_id=so.supplier_id, item_id=so.item_id, qt_number=so.qt_number, qt_round=so.qt_round, qt_type=so.qt_type, target_price=so.target_price, status=so.status, end_time=so.end_time, url=self._session_chat_url(so.session_id), ) for so in session_objs ] res.async_job = AsyncJob(status="created", message=f"협상 세션 {len(session_objs)}건 생성") return res async def stop_quotation(self, qt_id: str) -> Res_Quotation: 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 # 견적 '견적마감'(CLOSED) + 딸린 세션 정리를 한 트랜잭션으로. # 세션은 아직 시작 전(협상생성)인 것만 미참여로 떨군다. 협상중/완료/거부/미참여는 그대로 둔다. err_type = 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 ), ], ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res # 갱신 후 재조회 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