협상카드 - 목록/조회를 등록자 개인 → 회사(company.users 조인) 단위로 확장: 같은 회사 유저 카드 + 공용(user_id NULL) - 공용 카드는 기본 제공 자산 → 수정·삭제 불가(OWNER 포함), is_shared=True API 등록 거부 - 개인 카드 변경은 본인∪최고관리자만, 프론트 버튼 disable+툴팁 - 공개범위 셀렉터·엑셀 공개범위 컬럼·CardTable 전체 배지 제거(항상 개인 등록) - test_card.py 5케이스 재작성 견적 목표가 산정내역 - get_setting_rates: 단일 컬럼 select 결과를 이중 인덱싱해 항상 예외 → 마진율이 늘 0으로 적용되던 버그 수정 - get_target_breakdown: 채택 후보 판정을 '현재 최소값' → '저장 목표가와 값 일치'로 변경(산정 후 상품가 변동에도 초록 체크 유지) - 견적상세 협상카드 탭 세션ID 컬럼 제거 견적생성 모달 - 목표가 미리보기·마감기한 기본값(+1h)·미래 검증, combobox 팝오버 scrollIntoView, maskPrices 비율(%) 마스킹 Little-Helped: Stupid Claude
297 lines
14 KiB
Python
297 lines
14 KiB
Python
import uuid
|
|
|
|
from fastapi import Depends
|
|
|
|
from common.authz import is_owner_or_admin
|
|
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 두 테이블을 회사 단위로 스코프하고
|
|
프론트용 단일 모델(CardData, is_wildcard 플래그)로 합친다.
|
|
|
|
스코프/권한 규칙:
|
|
- 조회: 같은 회사 유저가 등록한 카드 + 전체(공용, user_id NULL) 카드
|
|
- 수정·삭제: 등록 본인 또는 최고관리자(OWNER). 공용 카드는 기본 제공 자산 — 누구도 불가
|
|
- 등록: 개인(회사) 카드만. 공용 카드는 API 로 만들 수 없다(DB 시드로만 관리)"""
|
|
|
|
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_visible(self, company_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 not await self._same_company(row.user_id, company_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 not await self._same_company(row.user_id, company_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 _same_company(self, owner_user_id, company_uuid: uuid.UUID) -> bool:
|
|
"""카드 소유자가 내 회사 소속인지. 공용(user_id NULL)은 모든 회사에서 접근 가능."""
|
|
if owner_user_id is None:
|
|
return True
|
|
err, owner_company = await DB_SESSION_MNG.execute_lambda(
|
|
nego_cards.DBType(),
|
|
DBWRType.DB_READ.value,
|
|
lambda s: self.card_crud.owner_company_id(s, owner_user_id),
|
|
)
|
|
return err == ErrorType.SUCCESS and owner_company == company_uuid
|
|
|
|
# ---- 목록 ----------------------------------------------------------------
|
|
async def list_cards(self, company_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 company_id:
|
|
return res
|
|
company_uuid = uuid.UUID(company_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, company_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, company_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, company_id: str, card_id: str) -> Res_Card:
|
|
res = Res_Card()
|
|
err, _model, _pk, row, is_wild = await self._find_visible(uuid.UUID(company_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, company_id: str, req: Req_CreateCard) -> Res_Card:
|
|
res = Res_Card()
|
|
user_uuid = uuid.UUID(user_id)
|
|
is_wildcard = req.is_wildcard
|
|
# 전체(공용, user_id NULL) 카드는 기본 제공 자산 — API 로 만들 수 없다(만들면 수정·삭제 불가에
|
|
# 전 회사 노출이라 되돌릴 수 없음). DB 시드로만 관리.
|
|
if req.is_shared:
|
|
res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN)
|
|
res.msg = "전체(공용) 카드는 등록할 수 없습니다."
|
|
return res
|
|
|
|
common = dict(
|
|
user_id=user_uuid,
|
|
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 not is_wildcard:
|
|
await notify_catalog_changed()
|
|
# 서버 기본값(created_at 등)은 insert 후 객체에 실리지 않으므로 재조회.
|
|
return await self.get_card(company_id, str(getattr(card, pk_attr)))
|
|
|
|
# ---- 수정 ----------------------------------------------------------------
|
|
async def update_card(self, user_id: str, company_id: str, role: int, card_id: str, req: Req_UpdateCard) -> Res_Card:
|
|
res = Res_Card()
|
|
card_uuid = uuid.UUID(card_id)
|
|
data = req.model_dump(exclude_unset=True)
|
|
|
|
err, model, pk_col, _row, is_wild = await self._find_visible(uuid.UUID(company_id), card_uuid)
|
|
if err != ErrorType.SUCCESS:
|
|
res.result.SetResult(err)
|
|
return res
|
|
# 공용(user_id NULL) 카드는 기본 제공 자산 — 수정 불가. 개인 카드는 본인 또는 최고관리자만.
|
|
if _row.user_id is None:
|
|
res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN)
|
|
res.msg = "전체(공용) 카드는 수정할 수 없습니다."
|
|
return res
|
|
if not is_owner_or_admin(_row.user_id, user_id, role):
|
|
res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN)
|
|
res.msg = "본인이 등록한 카드만 수정할 수 있습니다."
|
|
return res
|
|
|
|
# 해당 테이블에 있는 컬럼만 추린다(없는 필드는 무시). 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 not is_wild:
|
|
await notify_catalog_changed()
|
|
return await self.get_card(company_id, card_id)
|
|
|
|
# ---- 삭제(soft) ----------------------------------------------------------
|
|
async def delete_card(self, user_id: str, company_id: str, role: int, card_id: str) -> Res_DeleteCard:
|
|
res = Res_DeleteCard()
|
|
card_uuid = uuid.UUID(card_id)
|
|
|
|
err, model, pk_col, _row, _is_wild = await self._find_visible(uuid.UUID(company_id), card_uuid)
|
|
if err != ErrorType.SUCCESS:
|
|
res.result.SetResult(err)
|
|
return res
|
|
# 공용(user_id NULL) 카드는 기본 제공 자산 — 삭제 불가. 개인 카드는 본인 또는 최고관리자만.
|
|
if _row.user_id is None:
|
|
res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN)
|
|
res.msg = "전체(공용) 카드는 삭제할 수 없습니다."
|
|
return res
|
|
if not is_owner_or_admin(_row.user_id, user_id, role):
|
|
res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN)
|
|
res.msg = "본인이 등록한 카드만 삭제할 수 있습니다."
|
|
return res
|
|
|
|
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 not _is_wild:
|
|
await notify_catalog_changed()
|
|
return res
|