import uuid from fastapi import Depends from common.database.db_session_manager import DB_SESSION_MNG from common.database.model.models import nego_cards, wild_cards from common.enums import CardStatus, DBWRType, ErrorType from common.models.gmodel import PageParams from crud.card_crud import ICardCRUD, CardCRUD from services.agent_notify import notify_catalog_changed from router.v1.card.protocol import CardData, Req_CreateCard, Req_UpdateCard, Res_Card, Res_CardList, Res_DeleteCard class CardService: """협상카드 비즈니스 로직. nego_cards/wild_cards 두 테이블을 user_id 로 스코프하고 프론트용 단일 모델(CardData, is_wildcard 플래그)로 합친다.""" def __init__(self, card_crud: ICardCRUD = Depends(CardCRUD)): self.card_crud = card_crud # ---- 행 → 통합 CardData -------------------------------------------------- @staticmethod def _nego_to_data(row) -> CardData: # 일반 협상카드는 상태 개념이 없다(상시 적용) → ACTIVE 고정. return CardData( nego_card_id=row.nego_card_id, user_id=row.user_id, is_wildcard=False, is_shared=row.user_id is None, name=row.name, number=row.number, script=row.script, edit_script=row.edit_script, usage_type=row.usage_type, status=CardStatus.ACTIVE.value, created_at=row.created_at, updated_at=row.updated_at, ) @staticmethod def _wild_to_data(row) -> CardData: # 와일드카드 적용 여부(available) → status(ACTIVE/INACTIVE) 매핑. return CardData( nego_card_id=row.wild_card_id, user_id=row.user_id, is_wildcard=True, is_shared=row.user_id is None, name=row.name, number=row.number, script=row.script, edit_script=row.edit_script, usage_type=row.usage_type, status=CardStatus.ACTIVE.value if row.available else CardStatus.INACTIVE.value, condition=row.condition, memo=row.memo, created_at=row.created_at, updated_at=row.updated_at, ) # ---- 소유 카드 탐색(어느 테이블인지 모를 때) ------------------------------ async def _find_owned(self, user_uuid: uuid.UUID, card_id: uuid.UUID): """card_id 를 nego_cards → wild_cards 순으로 찾고 접근권 확인. 전체(공용, user_id NULL) 카드는 누구나 조회·수정·삭제 가능. 개인 카드는 소유자만. (ErrorType, model, pk_col, row, is_wildcard) 반환.""" err, row = await DB_SESSION_MNG.execute_lambda( nego_cards.DBType(), DBWRType.DB_READ.value, lambda s: self.card_crud.get_by_id(s, nego_cards, nego_cards.nego_card_id, card_id), ) if err == ErrorType.SUCCESS and row is not None: if row.user_id is not None and row.user_id != user_uuid: return ErrorType.CARD_NOT_FOUND, None, None, None, False return ErrorType.SUCCESS, nego_cards, nego_cards.nego_card_id, row, False err, row = await DB_SESSION_MNG.execute_lambda( wild_cards.DBType(), DBWRType.DB_READ.value, lambda s: self.card_crud.get_by_id(s, wild_cards, wild_cards.wild_card_id, card_id), ) if err == ErrorType.SUCCESS and row is not None: if row.user_id is not None and row.user_id != user_uuid: return ErrorType.CARD_NOT_FOUND, None, None, None, True return ErrorType.SUCCESS, wild_cards, wild_cards.wild_card_id, row, True return ErrorType.CARD_NOT_FOUND, None, None, None, False # ---- 목록 ---------------------------------------------------------------- async def list_cards(self, user_id: str, search, is_wildcard, pg: PageParams) -> Res_CardList: """is_wildcard: None=전체(두 테이블 머지) / False=협상카드만 / True=와일드카드만. 탭이 무엇이든 양쪽 카운트(total_nego/total_wild)는 항상 채운다(검색 필터 반영). 선택 안 된 탭은 limit=0 으로 카운트만 받아 행은 가져오지 않는다.""" res = Res_CardList(page=pg.page, size=pg.size) if not user_id: return res user_uuid = uuid.UUID(user_id) # 합쳐서 정렬/페이징하므로 각 테이블에서 skip+limit 까지 받아온다(카드 수가 적어 충분). fetch = pg.skip + pg.size nego_limit = 0 if is_wildcard is True else fetch wild_limit = 0 if is_wildcard is False else fetch err_n, nego_rows, total_n = await DB_SESSION_MNG.execute_lambda( nego_cards.DBType(), DBWRType.DB_READ.value, lambda s: self.card_crud.search(s, nego_cards, user_uuid, search, 0, nego_limit), ) if err_n != ErrorType.SUCCESS: res.result.SetResult(err_n) return res err_w, wild_rows, total_w = await DB_SESSION_MNG.execute_lambda( wild_cards.DBType(), DBWRType.DB_READ.value, lambda s: self.card_crud.search(s, wild_cards, user_uuid, search, 0, wild_limit), ) if err_w != ErrorType.SUCCESS: res.result.SetResult(err_w) return res merged = [self._nego_to_data(r) for r in nego_rows] + [self._wild_to_data(r) for r in wild_rows] merged.sort(key=lambda c: c.created_at or "", reverse=True) page = merged[pg.skip : pg.skip + pg.size] # 작성자명 배치 조인 — 페이지 카드의 작성자 id를 모아 IN 쿼리 1회로 {id:name} 맵을 만들어 매핑. # (공용 카드는 user_id=NULL → 맵에 없어 creator_name=None). 행마다 조회하지 않으므로 부하 없음. author_ids = list({c.user_id for c in page if c.user_id is not None}) if author_ids: nm_err, name_map = await DB_SESSION_MNG.execute_lambda( nego_cards.DBType(), DBWRType.DB_READ.value, lambda s: self.card_crud.user_name_map(s, author_ids), ) if nm_err == ErrorType.SUCCESS: for c in page: c.creator_name = name_map.get(c.user_id) res.cards = page res.total_nego = total_n res.total_wild = total_w # 선택된 탭 기준 페이지네이션 총건수(전체=합산). if is_wildcard is True: res.total = total_w elif is_wildcard is False: res.total = total_n else: res.total = total_n + total_w return res # ---- 단건 조회 ----------------------------------------------------------- async def get_card(self, user_id: str, card_id: str) -> Res_Card: res = Res_Card() err, _model, _pk, row, is_wild = await self._find_owned(uuid.UUID(user_id), uuid.UUID(card_id)) if err != ErrorType.SUCCESS: res.result.SetResult(err) return res res.card = self._wild_to_data(row) if is_wild else self._nego_to_data(row) # 등록자명 — 공용(user_id NULL) 카드는 작성자 없음(None 유지). if row.user_id is not None: nm_err, name_map = await DB_SESSION_MNG.execute_lambda( nego_cards.DBType(), DBWRType.DB_READ.value, lambda s: self.card_crud.user_name_map(s, [row.user_id]), ) if nm_err == ErrorType.SUCCESS: res.card.creator_name = name_map.get(row.user_id) return res # ---- 등록 ---------------------------------------------------------------- async def create_card(self, user_id: str, req: Req_CreateCard) -> Res_Card: res = Res_Card() user_uuid = uuid.UUID(user_id) is_wildcard = req.is_wildcard # 전체(공용) 카드는 소유자 없이 저장(user_id NULL) → 모든 유저 목록에 노출. owner_id = None if req.is_shared else user_uuid common = dict( user_id=owner_id, name=req.name, number=req.number, script=req.script, edit_script=req.edit_script, usage_type=req.usage_type, ) if is_wildcard: card = wild_cards( **common, condition=req.condition, available=(req.status == CardStatus.ACTIVE.value), memo=req.memo, ) model, pk_attr = wild_cards, "wild_card_id" else: card = nego_cards(**common) model, pk_attr = nego_cards, "nego_card_id" err = await DB_SESSION_MNG.execute_lambda_run( [model.DBType()], [lambda s: self.card_crud.add(s, card)], ) if err != ErrorType.SUCCESS: res.result.SetResult(err) return res # 공용 일반카드는 agent action space 를 정의 → 변경 알림(전역 엔진 재조립). 개인/와일드는 미해당. if req.is_shared and not is_wildcard: await notify_catalog_changed() # 서버 기본값(created_at 등)은 insert 후 객체에 실리지 않으므로 재조회. return await self.get_card(user_id, str(getattr(card, pk_attr))) # ---- 수정 ---------------------------------------------------------------- async def update_card(self, user_id: str, card_id: str, req: Req_UpdateCard) -> Res_Card: res = Res_Card() user_uuid = uuid.UUID(user_id) card_uuid = uuid.UUID(card_id) data = req.model_dump(exclude_unset=True) err, model, pk_col, _row, is_wild = await self._find_owned(user_uuid, card_uuid) if err != ErrorType.SUCCESS: res.result.SetResult(err) return res was_shared_nego = _row.user_id is None and not is_wild # 해당 테이블에 있는 컬럼만 추린다(없는 필드는 무시). status → available(와일드 전용). allowed = {"name", "number", "script", "edit_script", "usage_type"} if is_wild: allowed |= {"condition", "memo"} payload = {k: v for k, v in data.items() if k in allowed} if is_wild and "status" in data and data["status"] is not None: payload["available"] = data["status"] == CardStatus.ACTIVE.value err = await DB_SESSION_MNG.execute_lambda_run( [model.DBType()], [lambda s: self.card_crud.update(s, model, pk_col, card_uuid, payload)], ) if err != ErrorType.SUCCESS: res.result.SetResult(err) return res # 공용 일반카드 변경(번호/개수 등)은 action space 에 영향 → 알림. if was_shared_nego: await notify_catalog_changed() return await self.get_card(user_id, card_id) # ---- 삭제(soft) ---------------------------------------------------------- async def delete_card(self, user_id: str, card_id: str) -> Res_DeleteCard: res = Res_DeleteCard() user_uuid = uuid.UUID(user_id) card_uuid = uuid.UUID(card_id) err, model, pk_col, _row, _is_wild = await self._find_owned(user_uuid, card_uuid) if err != ErrorType.SUCCESS: res.result.SetResult(err) return res was_shared_nego = _row.user_id is None and not _is_wild err = await DB_SESSION_MNG.execute_lambda_run( [model.DBType()], [lambda s: self.card_crud.soft_delete(s, model, pk_col, card_uuid)], ) if err != ErrorType.SUCCESS: res.result.SetResult(err) return res # 공용 일반카드 삭제는 카탈로그 수 변경 → 알림. if was_shared_nego: await notify_catalog_changed() return res