[feat] negodata: 협상카드 회사 스코프·공용 읽기전용 + 견적 목표가 산정내역 수정
협상카드 - 목록/조회를 등록자 개인 → 회사(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
This commit is contained in:
parent
0d62440ee1
commit
1b9ca062f3
@ -14,7 +14,7 @@ from common.utils.gtime import GTime
|
||||
# 협상카드 CRUD. nego_cards/wild_cards 두 테이블에 공통으로 쓰는 제네릭 구현.
|
||||
class ICardCRUD(ABC):
|
||||
@abstractmethod
|
||||
async def search(self, cdb: AsyncSession, model, user_id, search, skip, limit) -> Tuple[ErrorType, list, int]:
|
||||
async def search(self, cdb: AsyncSession, model, company_id, search, skip, limit) -> Tuple[ErrorType, list, int]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
@ -37,16 +37,21 @@ class ICardCRUD(ABC):
|
||||
async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def owner_company_id(self, cdb: AsyncSession, owner_user_id) -> Tuple[ErrorType, Optional[object]]:
|
||||
pass
|
||||
|
||||
|
||||
class CardCRUD(ICardCRUD):
|
||||
async def search(
|
||||
self, cdb: AsyncSession, model, user_id, search: Optional[str], skip: int, limit: int
|
||||
self, cdb: AsyncSession, model, company_id, search: Optional[str], skip: int, limit: int
|
||||
) -> Tuple[ErrorType, list, int]:
|
||||
try:
|
||||
# 내 개인 카드 + 전체(공용, user_id NULL) 카드. 남의 개인 카드는 제외.
|
||||
# 회사 카드(같은 회사 유저 등록) + 전체(공용, user_id NULL) 카드. 타 회사 카드는 제외.
|
||||
company_users = select(users.user_id).where(users.company_id == company_id)
|
||||
conditions = [
|
||||
model.deleted == False, # noqa: E712
|
||||
or_(model.user_id == user_id, model.user_id.is_(None)),
|
||||
or_(model.user_id.in_(company_users), model.user_id.is_(None)),
|
||||
]
|
||||
if search:
|
||||
conditions.append(
|
||||
@ -126,3 +131,15 @@ class CardCRUD(ICardCRUD):
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, {}
|
||||
|
||||
async def owner_company_id(self, cdb: AsyncSession, owner_user_id) -> Tuple[ErrorType, Optional[object]]:
|
||||
"""카드 소유자(user_id) → 소속 company_id. 개인 카드의 회사 스코프 접근 판정용."""
|
||||
try:
|
||||
query = select(users.company_id).where(users.user_id == owner_user_id).limit(1)
|
||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
return err_type, None
|
||||
return ErrorType.SUCCESS, rows[0] if rows else None
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, None
|
||||
|
||||
@ -437,9 +437,10 @@ class QuotationCRUD(IQuotationCRUD):
|
||||
return err_type, {}
|
||||
if not rows:
|
||||
return ErrorType.SUCCESS, {}
|
||||
r = rows[0]
|
||||
# 단일 컬럼 select → execute 가 스칼라 리스트를 돌려준다(Row 아님).
|
||||
margin = rows[0]
|
||||
return ErrorType.SUCCESS, {
|
||||
"margin": float(r[0]) if r[0] is not None else None,
|
||||
"margin": float(margin) if margin is not None else None,
|
||||
}
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
|
||||
@ -24,28 +24,32 @@ async def list_cards(
|
||||
is_wildcard: bool | None = Query(None, description="탭 필터: 미지정=전체 / false=협상카드 / true=와일드카드"),
|
||||
pg: PageParams = Depends(),
|
||||
):
|
||||
return RemoveNoneResponse(await service.list_cards(user_info.user_id, search, is_wildcard, pg))
|
||||
return RemoveNoneResponse(await service.list_cards(user_info.company_id, search, is_wildcard, pg))
|
||||
|
||||
|
||||
@router.post(path="/create", response_model=Res_Card, summary="협상카드 등록")
|
||||
async def create_card(req: Req_CreateCard, service: CardService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
return RemoveNoneResponse(
|
||||
await service.create_card(user_info.user_id, req)
|
||||
await service.create_card(user_info.user_id, user_info.company_id, req)
|
||||
)
|
||||
|
||||
|
||||
@router.get(path="/{card_id}", response_model=Res_Card, summary="협상카드 조회")
|
||||
async def get_card(card_id: UUID, service: CardService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
return RemoveNoneResponse(await service.get_card(user_info.user_id, str(card_id)))
|
||||
return RemoveNoneResponse(await service.get_card(user_info.company_id, str(card_id)))
|
||||
|
||||
|
||||
@router.patch(path="/update/{card_id}", response_model=Res_Card, summary="협상카드 수정")
|
||||
async def update_card(
|
||||
card_id: UUID, req: Req_UpdateCard, service: CardService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
|
||||
):
|
||||
return RemoveNoneResponse(await service.update_card(user_info.user_id, str(card_id), req))
|
||||
return RemoveNoneResponse(
|
||||
await service.update_card(user_info.user_id, user_info.company_id, user_info.role, str(card_id), req)
|
||||
)
|
||||
|
||||
|
||||
@router.delete(path="/delete/{card_id}", response_model=Res_DeleteCard, summary="협상카드 삭제")
|
||||
async def delete_card(card_id: UUID, service: CardService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
return RemoveNoneResponse(await service.delete_card(user_info.user_id, str(card_id)))
|
||||
return RemoveNoneResponse(
|
||||
await service.delete_card(user_info.user_id, user_info.company_id, user_info.role, str(card_id))
|
||||
)
|
||||
|
||||
@ -14,7 +14,7 @@ class CardProtocol(WebPacketProtocol):
|
||||
|
||||
class Req_CreateCard(CardProtocol):
|
||||
is_wildcard: bool = False
|
||||
is_shared: bool = False # True=전체(공용, user_id NULL 저장) / False=개인(등록 유저 소유)
|
||||
is_shared: bool = False # True(전체 공용 등록)는 거부된다 — 공용 카드는 DB 시드로만 관리. 항상 False 로 보낼 것
|
||||
name: Optional[str] = None
|
||||
number: Optional[str] = None
|
||||
script: Optional[str] = None
|
||||
|
||||
@ -2,6 +2,7 @@ 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
|
||||
@ -12,8 +13,13 @@ from router.v1.card.protocol import CardData, Req_CreateCard, Req_UpdateCard, Re
|
||||
|
||||
|
||||
class CardService:
|
||||
"""협상카드 비즈니스 로직. nego_cards/wild_cards 두 테이블을 user_id 로 스코프하고
|
||||
프론트용 단일 모델(CardData, is_wildcard 플래그)로 합친다."""
|
||||
"""협상카드 비즈니스 로직. 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
|
||||
@ -57,10 +63,10 @@ class CardService:
|
||||
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) 카드는 누구나 조회·수정·삭제 가능. 개인 카드는 소유자만.
|
||||
# ---- 카드 탐색(어느 테이블인지 모를 때) -----------------------------------
|
||||
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(),
|
||||
@ -68,7 +74,7 @@ class CardService:
|
||||
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:
|
||||
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
|
||||
|
||||
@ -78,21 +84,32 @@ class CardService:
|
||||
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:
|
||||
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, user_id: str, search, is_wildcard, pg: PageParams) -> Res_CardList:
|
||||
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 user_id:
|
||||
if not company_id:
|
||||
return res
|
||||
user_uuid = uuid.UUID(user_id)
|
||||
company_uuid = uuid.UUID(company_id)
|
||||
# 합쳐서 정렬/페이징하므로 각 테이블에서 skip+limit 까지 받아온다(카드 수가 적어 충분).
|
||||
fetch = pg.skip + pg.size
|
||||
nego_limit = 0 if is_wildcard is True else fetch
|
||||
@ -101,7 +118,7 @@ class CardService:
|
||||
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),
|
||||
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)
|
||||
@ -110,7 +127,7 @@ class CardService:
|
||||
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),
|
||||
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)
|
||||
@ -145,9 +162,9 @@ class CardService:
|
||||
return res
|
||||
|
||||
# ---- 단건 조회 -----------------------------------------------------------
|
||||
async def get_card(self, user_id: str, card_id: str) -> Res_Card:
|
||||
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_owned(uuid.UUID(user_id), uuid.UUID(card_id))
|
||||
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
|
||||
@ -163,15 +180,19 @@ class CardService:
|
||||
return res
|
||||
|
||||
# ---- 등록 ----------------------------------------------------------------
|
||||
async def create_card(self, user_id: str, req: Req_CreateCard) -> Res_Card:
|
||||
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) → 모든 유저 목록에 노출.
|
||||
owner_id = None if req.is_shared else user_uuid
|
||||
# 전체(공용, user_id NULL) 카드는 기본 제공 자산 — API 로 만들 수 없다(만들면 수정·삭제 불가에
|
||||
# 전 회사 노출이라 되돌릴 수 없음). DB 시드로만 관리.
|
||||
if req.is_shared:
|
||||
res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN)
|
||||
res.msg = "전체(공용) 카드는 등록할 수 없습니다."
|
||||
return res
|
||||
|
||||
common = dict(
|
||||
user_id=owner_id,
|
||||
user_id=user_uuid,
|
||||
name=req.name,
|
||||
number=req.number,
|
||||
script=req.script,
|
||||
@ -197,24 +218,31 @@ class CardService:
|
||||
if err != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err)
|
||||
return res
|
||||
# 공용 일반카드는 agent action space 를 정의 → 변경 알림(전역 엔진 재조립). 개인/와일드는 미해당.
|
||||
if req.is_shared and not is_wildcard:
|
||||
# 일반 협상카드는 회사 카탈로그(agent action space)에 포함 → 변경 알림(엔진 재조립). 와일드는 미해당.
|
||||
if not is_wildcard:
|
||||
await notify_catalog_changed()
|
||||
# 서버 기본값(created_at 등)은 insert 후 객체에 실리지 않으므로 재조회.
|
||||
return await self.get_card(user_id, str(getattr(card, pk_attr)))
|
||||
return await self.get_card(company_id, str(getattr(card, pk_attr)))
|
||||
|
||||
# ---- 수정 ----------------------------------------------------------------
|
||||
async def update_card(self, user_id: str, card_id: str, req: Req_UpdateCard) -> Res_Card:
|
||||
async def update_card(self, user_id: str, company_id: str, role: int, 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)
|
||||
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
|
||||
was_shared_nego = _row.user_id is None and not is_wild
|
||||
# 공용(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"}
|
||||
@ -231,22 +259,29 @@ class CardService:
|
||||
if err != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err)
|
||||
return res
|
||||
# 공용 일반카드 변경(번호/개수 등)은 action space 에 영향 → 알림.
|
||||
if was_shared_nego:
|
||||
# 일반 협상카드 변경(번호 등)은 회사 카탈로그(action space)에 영향 → 알림.
|
||||
if not is_wild:
|
||||
await notify_catalog_changed()
|
||||
return await self.get_card(user_id, card_id)
|
||||
return await self.get_card(company_id, card_id)
|
||||
|
||||
# ---- 삭제(soft) ----------------------------------------------------------
|
||||
async def delete_card(self, user_id: str, card_id: str) -> Res_DeleteCard:
|
||||
async def delete_card(self, user_id: str, company_id: str, role: int, 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)
|
||||
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
|
||||
was_shared_nego = _row.user_id is None and not _is_wild
|
||||
# 공용(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()],
|
||||
@ -255,7 +290,7 @@ class CardService:
|
||||
if err != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err)
|
||||
return res
|
||||
# 공용 일반카드 삭제는 카탈로그 수 변경 → 알림.
|
||||
if was_shared_nego:
|
||||
# 일반 협상카드 삭제는 회사 카탈로그 수 변경 → 알림.
|
||||
if not _is_wild:
|
||||
await notify_catalog_changed()
|
||||
return res
|
||||
|
||||
@ -135,7 +135,9 @@ class QuotationService:
|
||||
async def get_target_breakdown(self, session_id: str, company_id=None) -> Res_TargetBreakdown:
|
||||
"""세션 목표가 산정내역(후보·채택). 저장된 target_price/anchoring 은 그대로 표기하고,
|
||||
후보값은 생성과 동일한 _candidates 로직으로 계산해 내려준다(프론트 재계산 제거 → 항상 일치).
|
||||
상속분(재생성 라운드)은 현재 후보와 무관하므로 is_inherited=True, 채택 표시는 비운다."""
|
||||
채택 표시는 저장 목표가와 값이 일치하는 후보로 판정한다 — 산정 이후 다른 후보(상품 가격)가
|
||||
변해 현재 최소값이 바뀌어도 출처 후보의 체크는 유지된다. 일치 후보가 없으면(재생성 상속,
|
||||
채택 후보 자체가 변경) is_inherited=True 로 채택 표시를 비운다."""
|
||||
res = Res_TargetBreakdown()
|
||||
err_type, got = await DB_SESSION_MNG.execute_lambda(
|
||||
sessions.DBType(),
|
||||
@ -169,11 +171,8 @@ class QuotationService:
|
||||
md = quotation.md_price
|
||||
|
||||
cands = self._candidates(md, internet, purchase, selling, fee, margin, is_new)
|
||||
chosen_basis, computed = None, None
|
||||
if cands:
|
||||
chosen_basis, chosen_val = min(cands, key=lambda c: c[1])
|
||||
computed = int(chosen_val)
|
||||
is_inherited = computed is None or computed != sess.target_price
|
||||
chosen_basis = next((b for b, v in cands if int(v) == sess.target_price), None)
|
||||
is_inherited = chosen_basis is None
|
||||
|
||||
res.is_new = is_new
|
||||
res.is_inherited = is_inherited
|
||||
@ -184,7 +183,7 @@ class QuotationService:
|
||||
res.fee = fee
|
||||
res.margin = margin
|
||||
res.candidates = [TargetCandidate(basis=b, label=self._CANDIDATE_LABELS.get(b, b), value=int(v)) for b, v in cands]
|
||||
res.chosen_basis = None if is_inherited else chosen_basis
|
||||
res.chosen_basis = chosen_basis
|
||||
res.target_price = sess.target_price
|
||||
res.anchoring_price = sess.anchoring_price
|
||||
res.anchoring_value = (sess.anchoring_value or 0) / 1000 # 세션 ‰ → 비율(main 프론트 '목표가×(1−값)' 표시용)
|
||||
|
||||
@ -1,13 +1,19 @@
|
||||
"""card 도메인 스코프 e2e — 개인 카드는 소유자만, 전체(공용) 카드는 누구나.
|
||||
스코프 규칙: user_id 있으면 개인(본인만 조회·관리) / NULL 이면 전체(모든 유저 조회·수정·삭제). 로그인은 auth_headers."""
|
||||
"""card 도메인 스코프 e2e — 회사 카드는 회사 전체 공유, 전체(공용) 카드는 읽기 전용.
|
||||
스코프 규칙: user_id 있으면 회사 카드(같은 회사 유저는 조회, 수정·삭제는 본인∪최고관리자) /
|
||||
NULL 이면 전체(공용) 카드(모든 회사 조회 가능, 수정·삭제·API 등록 불가 — DB 시드 전용). 로그인은 auth_headers."""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from common.enums import UserRole
|
||||
|
||||
|
||||
async def _create_card(client, headers, *, number, name="카드", is_shared=False, is_wildcard=False):
|
||||
async def _create_card(client, headers, *, number, name="카드", is_wildcard=False):
|
||||
r = await client.post(
|
||||
"/v1/card/create",
|
||||
json={
|
||||
"is_wildcard": is_wildcard,
|
||||
"is_shared": is_shared,
|
||||
"name": name,
|
||||
"number": number,
|
||||
"script": "안녕하세요",
|
||||
@ -19,47 +25,98 @@ async def _create_card(client, headers, *, number, name="카드", is_shared=Fals
|
||||
return body["card"]["nego_card_id"]
|
||||
|
||||
|
||||
async def _seed_shared_card(db_engine, *, number, name="공용카드"):
|
||||
"""전체(공용, user_id NULL) 카드는 API 로 못 만들므로 DB 에 직접 시드한다."""
|
||||
cid = uuid.uuid4()
|
||||
async with db_engine.begin() as conn:
|
||||
# usage_type 은 ORM 파이썬 default 뿐(server_default 없음) → raw INSERT 엔 명시.
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO nego_cards (nego_card_id, user_id, name, number, script, usage_type) "
|
||||
"VALUES (:cid, NULL, :name, :number, :script, 1)"
|
||||
),
|
||||
{"cid": cid, "name": name, "number": number, "script": "공용 스크립트"},
|
||||
)
|
||||
return str(cid)
|
||||
|
||||
|
||||
async def _list_numbers(client, headers):
|
||||
r = await client.get("/v1/card/list", headers=headers)
|
||||
return {c["number"] for c in r.json().get("cards", [])}
|
||||
|
||||
|
||||
async def test_personal_card_is_owner_only(client, auth_headers):
|
||||
"""검증: user_id 가 박힌 개인 카드는 소유자 목록·단건조회에만 노출되고 타 유저에겐 숨는다.
|
||||
기대결과: A 목록엔 있고 B 목록엔 없음, B 의 단건 조회는 success=False(CARD_NOT_FOUND)."""
|
||||
async def test_company_card_visible_to_colleague_not_other_company(client, auth_headers, other_company_id):
|
||||
"""검증: 개인(회사) 카드는 같은 회사 동료의 목록·단건조회에 노출되고 타 회사 유저에겐 숨는다.
|
||||
기대결과: 등록자 A·동료 B 목록엔 있고 타 회사 C 목록엔 없음, C 의 단건 조회는 success=False(CARD_NOT_FOUND)."""
|
||||
ha = await auth_headers("cardA")
|
||||
hb = await auth_headers("cardB")
|
||||
cid = await _create_card(client, ha, number="P-1", is_shared=False)
|
||||
hc = await auth_headers("cardC", other_company_id)
|
||||
cid = await _create_card(client, ha, number="P-1")
|
||||
|
||||
assert "P-1" in await _list_numbers(client, ha)
|
||||
assert "P-1" not in await _list_numbers(client, hb)
|
||||
assert (await client.get(f"/v1/card/{cid}", headers=hb)).json()["result"]["success"] is False
|
||||
assert "P-1" in await _list_numbers(client, hb)
|
||||
assert "P-1" not in await _list_numbers(client, hc)
|
||||
assert (await client.get(f"/v1/card/{cid}", headers=hb)).json()["result"]["success"] is True
|
||||
assert (await client.get(f"/v1/card/{cid}", headers=hc)).json()["result"]["success"] is False
|
||||
|
||||
|
||||
async def test_shared_card_visible_to_all(client, auth_headers):
|
||||
"""검증: is_shared=True 카드는 user_id NULL 로 저장돼 모든 유저 목록·단건조회에 노출된다.
|
||||
기대결과: A·B 목록 모두에 존재, 비생성자 B 의 단건 조회 success=True, is_shared 플래그 True."""
|
||||
async def test_shared_card_visible_to_all_companies(client, auth_headers, other_company_id, db_engine):
|
||||
"""검증: 전체(공용, user_id NULL) 카드는 회사와 무관하게 모든 유저 목록·단건조회에 노출된다.
|
||||
기대결과: 서로 다른 회사 A·C 목록 모두에 존재, 단건 조회 success=True + is_shared=True."""
|
||||
ha = await auth_headers("cardSA")
|
||||
hb = await auth_headers("cardSB")
|
||||
cid = await _create_card(client, ha, number="S-1", is_shared=True)
|
||||
hc = await auth_headers("cardSC", other_company_id)
|
||||
cid = await _seed_shared_card(db_engine, number="S-1")
|
||||
|
||||
assert "S-1" in await _list_numbers(client, ha)
|
||||
assert "S-1" in await _list_numbers(client, hb)
|
||||
got = await client.get(f"/v1/card/{cid}", headers=hb)
|
||||
assert "S-1" in await _list_numbers(client, hc)
|
||||
got = await client.get(f"/v1/card/{cid}", headers=hc)
|
||||
assert got.json()["result"]["success"] is True
|
||||
assert got.json()["card"]["is_shared"] is True
|
||||
|
||||
|
||||
async def test_shared_card_editable_and_deletable_by_anyone(client, auth_headers):
|
||||
"""검증: 전체(공용) 카드는 소유자가 없어 아무 유저나 수정·삭제 가능(정책: 누구나).
|
||||
기대결과: 비생성자 B 의 수정·삭제 모두 success=True, 삭제 후 목록에서 사라짐."""
|
||||
ha = await auth_headers("cardEA")
|
||||
hb = await auth_headers("cardEB")
|
||||
cid = await _create_card(client, ha, number="S-EDIT", is_shared=True)
|
||||
async def test_shared_card_immutable(client, auth_headers, db_engine):
|
||||
"""검증: 전체(공용) 카드는 기본 제공 자산이라 일반 유저는 물론 최고관리자도 수정·삭제할 수 없다.
|
||||
기대결과: USER 의 수정, OWNER 의 수정·삭제 모두 success=False, 카드는 목록에 남는다."""
|
||||
hu = await auth_headers("cardRU")
|
||||
ho = await auth_headers("cardRO", role=UserRole.OWNER.value)
|
||||
cid = await _seed_shared_card(db_engine, number="S-RO")
|
||||
|
||||
upd = await client.patch(f"/v1/card/update/{cid}", json={"name": "B가 수정"}, headers=hb)
|
||||
assert upd.json()["result"]["success"] is True
|
||||
upd = await client.patch(f"/v1/card/update/{cid}", json={"name": "수정 시도"}, headers=hu)
|
||||
assert upd.json()["result"]["success"] is False
|
||||
upd_owner = await client.patch(f"/v1/card/update/{cid}", json={"name": "수정 시도"}, headers=ho)
|
||||
assert upd_owner.json()["result"]["success"] is False
|
||||
dele = await client.delete(f"/v1/card/delete/{cid}", headers=ho)
|
||||
assert dele.json()["result"]["success"] is False
|
||||
assert "S-RO" in await _list_numbers(client, hu)
|
||||
|
||||
dele = await client.delete(f"/v1/card/delete/{cid}", headers=hb)
|
||||
|
||||
async def test_colleague_card_mutation_gating(client, auth_headers):
|
||||
"""검증: 같은 회사 동료의 카드는 조회는 되지만 수정·삭제는 본인 또는 최고관리자(OWNER)만 가능하다.
|
||||
기대결과: 동료 USER 의 수정 success=False, OWNER 의 수정·삭제는 success=True 후 목록에서 제거."""
|
||||
ha = await auth_headers("cardGA")
|
||||
hb = await auth_headers("cardGB")
|
||||
ho = await auth_headers("cardGO", role=UserRole.OWNER.value)
|
||||
cid = await _create_card(client, ha, number="P-G")
|
||||
|
||||
upd = await client.patch(f"/v1/card/update/{cid}", json={"name": "동료가 수정"}, headers=hb)
|
||||
assert upd.json()["result"]["success"] is False
|
||||
|
||||
upd_owner = await client.patch(f"/v1/card/update/{cid}", json={"name": "관리자가 수정"}, headers=ho)
|
||||
assert upd_owner.json()["result"]["success"] is True
|
||||
|
||||
dele = await client.delete(f"/v1/card/delete/{cid}", headers=ho)
|
||||
assert dele.json()["result"]["success"] is True
|
||||
assert "S-EDIT" not in await _list_numbers(client, hb)
|
||||
assert "P-G" not in await _list_numbers(client, ha)
|
||||
|
||||
|
||||
async def test_create_shared_card_rejected(client, auth_headers):
|
||||
"""검증: is_shared=True 등록은 거부된다 — 공용 카드는 DB 시드로만 관리(만들면 수정·삭제 불가라 되돌릴 수 없음).
|
||||
기대결과: success=False, 카드는 목록에 생기지 않는다."""
|
||||
ha = await auth_headers("cardXA")
|
||||
r = await client.post(
|
||||
"/v1/card/create",
|
||||
json={"is_shared": True, "name": "공용 시도", "number": "S-X", "script": "안녕하세요"},
|
||||
headers=ha,
|
||||
)
|
||||
assert r.json()["result"]["success"] is False
|
||||
assert "S-X" not in await _list_numbers(client, ha)
|
||||
|
||||
@ -126,7 +126,7 @@ async def _quotation_policy(engine, qt_id):
|
||||
)).one()
|
||||
|
||||
|
||||
async def _seed_item(engine, company_id, *, internet_lowest):
|
||||
async def _seed_item(engine, company_id, *, internet_lowest, price=100_000):
|
||||
"""상품 1건 시드(인터넷최저가만). category_type·internet_lowest_price_yn 은 NOT NULL —
|
||||
ORM default 는 raw INSERT 에 안 먹으므로 명시한다(conftest companies.status 와 같은 이유)."""
|
||||
item_id = uuid.uuid4()
|
||||
@ -135,11 +135,11 @@ async def _seed_item(engine, company_id, *, internet_lowest):
|
||||
text(
|
||||
"INSERT INTO items "
|
||||
"(item_id, company_id, user_id, name, category_type, "
|
||||
" internet_lowest_price_yn, internet_lowest_price) VALUES "
|
||||
"(:item_id, :company_id, :user_id, '상품', 1, false, :ilp)"
|
||||
" price, internet_lowest_price_yn, internet_lowest_price) VALUES "
|
||||
"(:item_id, :company_id, :user_id, '상품', 1, :price, false, :ilp)"
|
||||
),
|
||||
{"item_id": item_id, "company_id": uuid.UUID(company_id),
|
||||
"user_id": uuid.uuid4(), "ilp": internet_lowest},
|
||||
"user_id": uuid.uuid4(), "price": price, "ilp": internet_lowest},
|
||||
)
|
||||
return item_id
|
||||
|
||||
|
||||
@ -57,6 +57,7 @@ export function Combobox({
|
||||
const [text, setText] = useState('');
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 검색어 디바운스 → onQueryChange. 콜백은 ref로 잡아 text 변화에만 반응.
|
||||
const qcRef = useRef(onQueryChange);
|
||||
@ -76,6 +77,13 @@ export function Combobox({
|
||||
return () => document.removeEventListener('mousedown', onDoc);
|
||||
}, [variant]);
|
||||
|
||||
// field 팝오버가 열리면 스크롤 컨테이너(모달 등) 안에서 잘리지 않게 뷰로 끌어온다.
|
||||
// 로딩→목록 로 높이가 커질 때도 다시 당겨온다(이미 보이면 scrollIntoView 는 no-op).
|
||||
useEffect(() => {
|
||||
if (variant === 'inline' || !open) return;
|
||||
popoverRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}, [open, variant, loading, options.length]);
|
||||
|
||||
const isSelected = (oid: string) => (multiple ? values.includes(oid) : value === oid);
|
||||
|
||||
const handlePick = (opt: ComboOption) => {
|
||||
@ -168,7 +176,7 @@ export function Combobox({
|
||||
<ChevronsUpDown size={14} className="text-muted-foreground shrink-0" />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute z-50 mt-1 w-full rounded border border-border bg-card shadow-lg p-1.5 space-y-1.5">
|
||||
<div ref={popoverRef} className="absolute z-50 mt-1 w-full rounded border border-border bg-card shadow-lg p-1.5 space-y-1.5">
|
||||
{searchInput}
|
||||
{list}
|
||||
</div>
|
||||
|
||||
@ -19,19 +19,16 @@ type RawRow = {
|
||||
title: string; // 카드이름
|
||||
script: string; // 스크립트(평문 — 변수는 {변수} 텍스트로)
|
||||
usage: string; // 카드용도 원문(공통/신규견적전용/재견적전용)
|
||||
scope: string; // 공개범위 원문(개인/전체)
|
||||
condition: string; // 와일드 전용 사용조건
|
||||
memo: string; // 와일드 전용 메모
|
||||
};
|
||||
|
||||
type ValidatedRow = RawRow & { status: '정상' | '오류'; message: string };
|
||||
|
||||
type TemplateRow = { kind: string; code: string; title: string; script: string; usage: string; scope: string; condition: string; memo: string };
|
||||
type TemplateRow = { kind: string; code: string; title: string; script: string; usage: string; condition: string; memo: string };
|
||||
|
||||
// 카드종류 원문 → 와일드 여부. '와일드'/'wild' 포함이면 와일드, 그 외 협상.
|
||||
const isWild = (kind: string) => /와일드|wild/i.test(kind.trim());
|
||||
// 공개범위 원문 → 전체(공용) 여부. '전체'/'공용'/'all' 이면 공용, 그 외 개인.
|
||||
const isShared = (scope: string) => /전체|공용|all/i.test(scope.trim());
|
||||
// 카드용도 원문 → CardUsageType 코드. '신규'→NEW '재'→REUSE 그 외 COMMON.
|
||||
function usageCode(usage: string): number {
|
||||
const u = usage.trim();
|
||||
@ -59,8 +56,7 @@ function validateRows(rows: RawRow[], serverErrors: Record<string, string>): Val
|
||||
if (serverErrors[row.code]) return fail(serverErrors[row.code]);
|
||||
|
||||
const kind = isWild(row.kind) ? '와일드' : '협상';
|
||||
const scope = isShared(row.scope) ? '전체' : '개인';
|
||||
return { ...row, status: '정상', message: `등록 적격 - ${kind}카드 · ${scope}` };
|
||||
return { ...row, status: '정상', message: `등록 적격 - ${kind}카드` };
|
||||
});
|
||||
}
|
||||
|
||||
@ -73,7 +69,6 @@ function toCardInput(row: RawRow): CardInput {
|
||||
editorScript: deserialize(undefined, row.script),
|
||||
status: 'ACTIVE',
|
||||
isWildcard: wild,
|
||||
isShared: isShared(row.scope),
|
||||
usageType: usageCode(row.usage),
|
||||
triggerCondition: wild ? row.condition : undefined,
|
||||
memo: wild ? row.memo : undefined,
|
||||
@ -115,7 +110,6 @@ export function CardExcelUploadModal({ open, onConfirm, onClose }: CardExcelUplo
|
||||
title: r['카드이름'] ?? '',
|
||||
script: r['스크립트'] ?? '',
|
||||
usage: r['카드용도'] ?? '',
|
||||
scope: r['공개범위'] ?? '',
|
||||
condition: r['사용조건'] ?? '',
|
||||
memo: r['메모'] ?? '',
|
||||
}));
|
||||
@ -244,7 +238,6 @@ export function CardExcelUploadModal({ open, onConfirm, onClose }: CardExcelUplo
|
||||
<TableHead className="p-2 font-semibold">카드이름 *</TableHead>
|
||||
<TableHead className="p-2 font-semibold">스크립트 *</TableHead>
|
||||
<TableHead className="p-2 font-semibold w-16">용도</TableHead>
|
||||
<TableHead className="p-2 font-semibold w-16">공개</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="divide-y divide-border">
|
||||
@ -304,9 +297,6 @@ export function CardExcelUploadModal({ open, onConfirm, onClose }: CardExcelUplo
|
||||
<TableCell className="p-2 text-[10px] text-muted-foreground whitespace-nowrap">
|
||||
{usageCode(row.usage) === CardUsageType.NEW ? '신규' : usageCode(row.usage) === CardUsageType.REUSE ? '재' : '공통'}
|
||||
</TableCell>
|
||||
<TableCell className="p-2 text-[10px] text-muted-foreground whitespace-nowrap">
|
||||
{isShared(row.scope) ? '전체' : '개인'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@ -360,13 +350,12 @@ export function downloadCardTemplate() {
|
||||
{ header: '카드이름', value: (r) => r.title },
|
||||
{ header: '스크립트', value: (r) => r.script },
|
||||
{ header: '카드용도', value: (r) => r.usage },
|
||||
{ header: '공개범위', value: (r) => r.scope },
|
||||
{ header: '사용조건', value: (r) => r.condition },
|
||||
{ header: '메모', value: (r) => r.memo },
|
||||
],
|
||||
[
|
||||
{ kind: '협상', code: 'CARD-EX-01', title: '예시) 최우수 등급 부여 카드', script: '귀사를 최우수 협력사로 지정하여 {목표가} 조건을 제안드립니다.', usage: '공통', scope: '개인', condition: '', memo: '' },
|
||||
{ kind: '와일드', code: 'WILD-EX-01', title: '예시) 원자재 급등 대응 카드', script: '원자재 시세 급등에 따른 단가 재조정을 요청드립니다.', usage: '공통', scope: '전체', condition: '원부자재 시세가 계약일 대비 3.5% 상회 시', memo: '특정 원재료 포함 입찰에만 적용' },
|
||||
{ kind: '협상', code: 'CARD-EX-01', title: '예시) 최우수 등급 부여 카드', script: '귀사를 최우수 협력사로 지정하여 {목표가} 조건을 제안드립니다.', usage: '공통', condition: '', memo: '' },
|
||||
{ kind: '와일드', code: 'WILD-EX-01', title: '예시) 원자재 급등 대응 카드', script: '원자재 시세 급등에 따른 단가 재조정을 요청드립니다.', usage: '공통', condition: '원부자재 시세가 계약일 대비 3.5% 상회 시', memo: '특정 원재료 포함 입찰에만 적용' },
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import { useForm, Controller } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import type { Descendant } from 'slate';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { showToast } from '@/lib/notify';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@ -16,7 +17,6 @@ import { CardScriptEditor, deserialize, serializeToText } from '../editor';
|
||||
|
||||
const schema = z.object({
|
||||
isWildcard: z.boolean(),
|
||||
isShared: z.boolean(),
|
||||
usageType: z.number(),
|
||||
code: z.string().trim().min(1, '카드번호는 필수 기입 사항입니다.'),
|
||||
title: z.string().trim().min(1, '카드이름은 필수 기입 사항입니다.'),
|
||||
@ -49,7 +49,6 @@ function buildDefaults(
|
||||
if (mode === 'edit' && card) {
|
||||
return {
|
||||
isWildcard: card.isWildcard,
|
||||
isShared: card.isShared,
|
||||
usageType: card.usageType,
|
||||
code: card.code,
|
||||
title: card.title,
|
||||
@ -63,7 +62,6 @@ function buildDefaults(
|
||||
const wild = activeTab === 'WILD';
|
||||
return {
|
||||
isWildcard: wild,
|
||||
isShared: false, // 기본: 개인(나만) — 전체 공용은 등록 시 명시 선택
|
||||
usageType: CardUsageType.COMMON, // 기본: 공통(신규·재 모두)
|
||||
code: generateCardCode(wild),
|
||||
title: '',
|
||||
@ -101,6 +99,15 @@ export function CardFormSheet({
|
||||
|
||||
const isWildcard = watch('isWildcard');
|
||||
|
||||
// 수정·삭제 게이팅 — 공용(기본 제공) 카드는 누구도 불가, 개인 카드는 본인 또는 최고관리자만(백엔드와 동일 규칙).
|
||||
const myUserId = useAuthStore((s) => s.user?.userId);
|
||||
const isSuperAdmin = useAuthStore((s) => s.user?.role === '최고관리자');
|
||||
const canMutate =
|
||||
mode === 'create' || (!!card && !card.isShared && (card.userId === myUserId || isSuperAdmin));
|
||||
const mutateBlockReason = card?.isShared
|
||||
? '전체(공용) 카드는 수정·삭제할 수 없습니다.'
|
||||
: '본인이 등록한 카드만 수정·삭제할 수 있습니다.';
|
||||
|
||||
const onValid = async (v: FormValues) => {
|
||||
const input: CardInput = {
|
||||
title: v.title,
|
||||
@ -108,7 +115,6 @@ export function CardFormSheet({
|
||||
editorScript: v.editorScript,
|
||||
status: v.status,
|
||||
isWildcard: v.isWildcard,
|
||||
isShared: v.isShared,
|
||||
usageType: v.usageType,
|
||||
triggerCondition: v.triggerCondition,
|
||||
memo: v.memo,
|
||||
@ -241,34 +247,6 @@ export function CardFormSheet({
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{/* 공개 범위(scope): 개인=나만 / 전체=모두 공용(user_id NULL). 등록 시에만 결정, 수정 시 고정 */}
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="small" className="font-semibold">공개 범위</Typography>
|
||||
<Controller
|
||||
control={control}
|
||||
name="isShared"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={field.value ? 'ALL' : 'MINE'}
|
||||
onValueChange={(v) => {
|
||||
if (mode !== 'create') return; // 스코프는 등록 시에만 결정(수정 시 이동 불가)
|
||||
field.onChange(v === 'ALL');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="form-card-scope" className="w-full" disabled={mode === 'edit'}>
|
||||
<SelectValue>
|
||||
{(value) => (value === 'ALL' ? '전체 (모두 공용)' : '개인 (나만)')}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="MINE">개인 (나만)</SelectItem>
|
||||
<SelectItem value="ALL">전체 (모두 공용)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 카드 용도(usage_type): 공통 / 신규전용 / 재전용 */}
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="small" className="font-semibold">카드 용도</Typography>
|
||||
@ -354,13 +332,15 @@ export function CardFormSheet({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer — 다른 폼 sheet와 동일: 좌측 삭제(edit), 우측 취소/저장 */}
|
||||
{/* Footer — 다른 폼 sheet와 동일: 좌측 삭제(edit), 우측 취소/저장. 권한 없으면 비활성(툴팁 사유) */}
|
||||
<div className="pt-4 flex items-center justify-between gap-2 border-t border-border mt-8">
|
||||
{mode === 'edit' && card && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={!canMutate}
|
||||
title={canMutate ? undefined : mutateBlockReason}
|
||||
onClick={() => {
|
||||
onDelete(card.id, card.title);
|
||||
onClose();
|
||||
@ -373,7 +353,13 @@ export function CardFormSheet({
|
||||
<Button type="button" variant="outline" size="sm" onClick={onClose}>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit" id="form-card-submit" size="sm" disabled={isSubmitting}>
|
||||
<Button
|
||||
type="submit"
|
||||
id="form-card-submit"
|
||||
size="sm"
|
||||
disabled={isSubmitting || !canMutate}
|
||||
title={canMutate ? undefined : mutateBlockReason}
|
||||
>
|
||||
{mode === 'create' ? '저장' : '수정'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -21,24 +21,16 @@ export function CardTable({ data, onEdit, footer }: CardTableProps) {
|
||||
{
|
||||
header: '구분',
|
||||
headClassName: 'w-24',
|
||||
cell: (card) => (
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
{card.isWildcard ? (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold bg-rose-50 text-rose-700 dark:bg-rose-950/20 dark:text-rose-400 border border-rose-200/40">
|
||||
와일드카드
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold bg-blue-50 text-blue-700 dark:bg-blue-950/20 dark:text-blue-400 border border-blue-200/40">
|
||||
협상카드
|
||||
</span>
|
||||
)}
|
||||
{card.isShared && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold bg-violet-50 text-violet-700 dark:bg-violet-950/20 dark:text-violet-400 border border-violet-200/40">
|
||||
전체
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
cell: (card) =>
|
||||
card.isWildcard ? (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold bg-rose-50 text-rose-700 dark:bg-rose-950/20 dark:text-rose-400 border border-rose-200/40">
|
||||
와일드카드
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold bg-blue-50 text-blue-700 dark:bg-blue-950/20 dark:text-blue-400 border border-blue-200/40">
|
||||
협상카드
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: '카드번호',
|
||||
|
||||
@ -22,24 +22,24 @@ export type CardInput = {
|
||||
editorScript: Descendant[];
|
||||
status: 'ACTIVE' | 'INACTIVE';
|
||||
isWildcard: boolean;
|
||||
isShared: boolean; // 전체(공용, user_id NULL) 등록 여부. 등록 시에만 유효(수정 시 스코프 고정)
|
||||
usageType: number; // usage_type(CardUsageType): 1=공통 2=신규견적전용 3=재견적전용
|
||||
triggerCondition?: string;
|
||||
memo?: string;
|
||||
};
|
||||
|
||||
// 서버 공통응답(result.success=false)을 한글 사유로 변환. 정상이면 null.
|
||||
// msg(권한 사유 등 사람용 문구)가 있으면 우선, 없으면 에러코드명(desc).
|
||||
function cardError(res: ResCard): string | null {
|
||||
const r = res.result;
|
||||
if (!r || r.success !== false) return null;
|
||||
return r.desc || '카드 저장에 실패했습니다.';
|
||||
return (typeof res.msg === 'string' && res.msg) || r.desc || '카드 저장에 실패했습니다.';
|
||||
}
|
||||
|
||||
// UI 입력 → 서버 요청 본문. 와일드카드 전용 필드(condition/memo)는 isWildcard 일 때만 전송.
|
||||
// 공용(is_shared) 등록은 폐지 — 카드는 항상 등록자 개인(회사 공유) 소유.
|
||||
function toReq(input: CardInput): ReqCreateCard {
|
||||
return {
|
||||
is_wildcard: input.isWildcard,
|
||||
is_shared: input.isShared,
|
||||
usage_type: input.usageType,
|
||||
name: input.title,
|
||||
number: input.code,
|
||||
@ -72,7 +72,10 @@ export function useCards(params: ListCardsParams) {
|
||||
await refresh();
|
||||
};
|
||||
const deleteCardFn = async (id: string) => {
|
||||
await deleteCard(id);
|
||||
const res = await deleteCard(id);
|
||||
if (res.result?.success === false) {
|
||||
throw new Error((typeof res.msg === 'string' && res.msg) || res.result.desc || '카드 삭제에 실패했습니다.');
|
||||
}
|
||||
await refresh();
|
||||
};
|
||||
|
||||
|
||||
@ -18,6 +18,7 @@ export function mapCardData(c: CardData): NegotiationCard {
|
||||
id: c.nego_card_id,
|
||||
isWildcard: c.is_wildcard ?? false,
|
||||
isShared: c.is_shared ?? false,
|
||||
userId: c.user_id ?? undefined,
|
||||
usageType: c.usage_type ?? CardUsageType.COMMON,
|
||||
code: c.number ?? '',
|
||||
title: c.name ?? '',
|
||||
|
||||
@ -27,13 +27,34 @@ import {
|
||||
import { supplierTypeLabel } from '@/lib/enumLabels';
|
||||
import { showToast } from '@/lib/notify';
|
||||
|
||||
// datetime-local 디폴트값: 현재 한국시간(Asia/Seoul)의 'YYYY-MM-DDTHH:mm'.
|
||||
const INTERNET_AVERAGE_FEE = 0.078;
|
||||
const TARGET_PRICE_UNIT_LIMIT_MULTIPLIER = 2;
|
||||
|
||||
// datetime-local 값: 한국시간(Asia/Seoul)의 'YYYY-MM-DDTHH:mm'.
|
||||
// sv-SE 로케일이 'YYYY-MM-DD HH:mm:ss' 를 주고, timeZone 명시로 브라우저 TZ 와 무관하게 KST 로 고정한다.
|
||||
function nowKstLocalInput(): string {
|
||||
const s = new Date().toLocaleString('sv-SE', { timeZone: 'Asia/Seoul' });
|
||||
function toKstLocalInput(date: Date): string {
|
||||
const s = date.toLocaleString('sv-SE', { timeZone: 'Asia/Seoul' });
|
||||
return s.slice(0, 16).replace(' ', 'T');
|
||||
}
|
||||
|
||||
function nowKstLocalInput(): string {
|
||||
return toKstLocalInput(new Date());
|
||||
}
|
||||
|
||||
function defaultDueDateLocalInput(): string {
|
||||
return toKstLocalInput(new Date(Date.now() + 60 * 60 * 1000));
|
||||
}
|
||||
|
||||
function isFutureLocalInput(value: string): boolean {
|
||||
const time = new Date(value).getTime();
|
||||
return Number.isFinite(time) && time > Date.now();
|
||||
}
|
||||
|
||||
function parsePercent(value: string | undefined): number {
|
||||
const n = Number(String(value ?? '').replace('%', '').trim());
|
||||
return Number.isFinite(n) ? n / 100 : 0;
|
||||
}
|
||||
|
||||
type QuotationCreateModalProps = {
|
||||
open: boolean;
|
||||
products: Product[];
|
||||
@ -62,7 +83,7 @@ export function QuotationCreateModal({
|
||||
const [isNew, setIsNew] = useState(true); // 신규 / 후속(재)
|
||||
const [productId, setProductId] = useState('');
|
||||
const [selectedPartnerIds, setSelectedPartnerIds] = useState<string[]>([]);
|
||||
const [dueDate, setDueDate] = useState(nowKstLocalInput);
|
||||
const [dueDate, setDueDate] = useState(defaultDueDateLocalInput);
|
||||
const [settingId, setSettingId] = useState(quotationSettings[0]?.qt_setting_id ?? '');
|
||||
const [selectedCardIds, setSelectedCardIds] = useState<string[]>([]);
|
||||
// 선택 항목 표시데이터 캐시 — 담는 순간 이름/이메일·카드메타를 적재해, 검색어가 바뀌어 콤보 목록에서 빠져도 아래 선택 테이블이 유지되게 한다.
|
||||
@ -97,6 +118,7 @@ export function QuotationCreateModal({
|
||||
// 선택 상품은 검색으로 목록이 좁혀져도 파생값(목표가 후보)이 안 깨지게 id로 단건 조회한다.
|
||||
const selItem = useGetItem(productId, { query: { enabled: !!productId } }).data?.item ?? null;
|
||||
// 인터넷최저가·매입가·판매가는 상품 속성 — 모달에선 읽기전용으로만 보여주고, 수정은 상품 상세에서 한다.
|
||||
const unitPrice = selItem?.price ?? null;
|
||||
const internetLowest = selItem?.internet_lowest_price ?? null;
|
||||
const purchase = selItem?.purchase_price ?? null;
|
||||
const selling = selItem?.selling_price ?? null;
|
||||
@ -167,6 +189,20 @@ export function QuotationCreateModal({
|
||||
const mdRequired = !!productId && !hasItemCandidate;
|
||||
// 목표가 산정 가능 여부: MD가가 있으면 무조건 OK. 없으면 상품 후보 중 하나라도 있어야.
|
||||
const targetReady = mdNum > 0 || hasItemCandidate;
|
||||
const selectedSetting = quotationSettings.find((s) => s.qt_setting_id === settingId);
|
||||
const margin = parsePercent(selectedSetting?.target_margin);
|
||||
const targetCandidates = mdNum > 0
|
||||
? [mdNum]
|
||||
: [
|
||||
internetLowest != null ? internetLowest * (1 - INTERNET_AVERAGE_FEE) : null,
|
||||
!isReType || purchase == null ? null : purchase,
|
||||
!isReType || selling == null ? null : selling * (1 - margin),
|
||||
].filter((v): v is number => v != null && v > 0);
|
||||
const estimatedTargetPrice = targetCandidates.length ? Math.trunc(Math.min(...targetCandidates)) : null;
|
||||
const targetPriceLimit = unitPrice != null && unitPrice > 0
|
||||
? unitPrice * TARGET_PRICE_UNIT_LIMIT_MULTIPLIER
|
||||
: null;
|
||||
const targetLimitExceeded = targetPriceLimit != null && estimatedTargetPrice != null && estimatedTargetPrice > targetPriceLimit;
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
@ -206,6 +242,14 @@ export function QuotationCreateModal({
|
||||
showToast('목표가 산정에 쓸 값이 없습니다 — MD 제시가를 입력하거나, 상품 상세에서 인터넷최저가·매입가를 채워주세요.', 'error');
|
||||
return; // finally 에서 submitting 해제
|
||||
}
|
||||
if (!isFutureLocalInput(dueDate)) {
|
||||
showToast('마감기한은 현재 시각보다 나중으로 설정해 주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
if (targetLimitExceeded) {
|
||||
showToast('목표가는 상품단가의 2배를 초과할 수 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
// 낙찰 기준은 1:1 협상만 전송(경매는 미전송 → 서버가 mid=over=AWARD 강제). 카드도 1:1 전용.
|
||||
const ok = await onCreate({
|
||||
title,
|
||||
@ -303,6 +347,7 @@ export function QuotationCreateModal({
|
||||
type="datetime-local"
|
||||
className="w-full p-2 bg-background border border-border rounded text-xs"
|
||||
value={dueDate}
|
||||
min={nowKstLocalInput()}
|
||||
onChange={(e) => setDueDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
@ -382,11 +427,29 @@ export function QuotationCreateModal({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 border-t border-border/60 pt-2">
|
||||
{[
|
||||
{ label: '상품단가', value: unitPrice },
|
||||
{ label: '목표가 상한', value: targetPriceLimit },
|
||||
].map((r) => (
|
||||
<div key={r.label} className="space-y-0.5">
|
||||
<Typography as="span" variant="label" className="text-muted-foreground">{r.label}</Typography>
|
||||
<Typography as="span" variant="small" className="font-mono block">
|
||||
{r.value != null ? `₩${Number(r.value).toLocaleString()}` : '-'}
|
||||
</Typography>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!targetReady && (
|
||||
<Typography as="p" variant="small" className="text-rose-600 leading-snug">
|
||||
⚠ MD 제시가도 없고 상품에 산정할 값도 없습니다 — MD가를 입력하거나 위 ‘상품 상세에서 수정’으로 값을 채워야 목표가가 나옵니다.
|
||||
</Typography>
|
||||
)}
|
||||
{targetLimitExceeded && (
|
||||
<Typography as="p" variant="small" className="text-rose-600 leading-snug">
|
||||
목표가 예상값 ₩{estimatedTargetPrice?.toLocaleString()}이 상품단가의 2배를 초과합니다 — MD 제시가 또는 상품 가격 정보를 조정해 주세요.
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -545,6 +608,14 @@ export function QuotationCreateModal({
|
||||
showToast('목표가 산정에 쓸 값이 없습니다 — MD 제시가를 입력하거나 상품 상세에서 값을 채워주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
if (step === 1 && !isFutureLocalInput(dueDate)) {
|
||||
showToast('마감기한은 현재 시각보다 나중으로 설정해 주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
if (step === 1 && targetLimitExceeded) {
|
||||
showToast('목표가는 상품단가의 2배를 초과할 수 없습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
setStep((prev) => prev + 1);
|
||||
}}
|
||||
>
|
||||
|
||||
@ -13,7 +13,6 @@ export function QuotationCardsTab({ quotationCardViews }: { quotationCardViews:
|
||||
<Table className="w-full text-left text-xs border-collapse font-mono">
|
||||
<TableHeader className="bg-muted text-muted-foreground text-[10px] border-b border-border">
|
||||
<TableRow>
|
||||
<TableHead className="p-3 font-semibold">세션 카드 ID</TableHead>
|
||||
<TableHead className="p-3 font-semibold font-sans">카드 이름</TableHead>
|
||||
<TableHead className="p-3 font-semibold font-sans">타입</TableHead>
|
||||
</TableRow>
|
||||
@ -22,7 +21,6 @@ export function QuotationCardsTab({ quotationCardViews }: { quotationCardViews:
|
||||
{quotationCardViews.length > 0 ? (
|
||||
quotationCardViews.map((qc) => (
|
||||
<TableRow key={qc.session_card_id} className="hover:bg-muted/30 transition-colors text-[11px]">
|
||||
<TableCell className="p-3 text-muted-foreground">{qc.session_card_id}</TableCell>
|
||||
<TableCell className="p-3 text-foreground font-sans font-semibold">
|
||||
{qc.card_id ? (
|
||||
<Link
|
||||
@ -45,7 +43,7 @@ export function QuotationCardsTab({ quotationCardViews }: { quotationCardViews:
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="p-12 text-center text-muted-foreground">
|
||||
<TableCell colSpan={2} className="p-12 text-center text-muted-foreground">
|
||||
사용된 협상 카드가 없습니다. (리스트가 비어 있습니다)
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@ -83,7 +83,7 @@ export function TargetPriceModal({
|
||||
</Typography>
|
||||
{bd.is_inherited && (
|
||||
<Typography as="p" variant="small" className="text-[10px] text-amber-600">
|
||||
* 저장된 목표가가 현재 후보 최소값과 다름 — 재생성 상속 또는 산정 후 상품·세팅 변경(아래 후보는 현재값 기준 참고용)
|
||||
* 저장된 목표가와 일치하는 현재 후보 없음 — 재생성 상속 또는 산정 후 상품·세팅 변경(아래 후보는 현재값 기준 참고용)
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -5,9 +5,10 @@ export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
// 문자열에서 금액(원/₩) 표기의 숫자만 '***'로 가린다. 접미사·문장 구조는 유지.
|
||||
// 문자열에서 금액(원/₩)·비율(%) 표기의 숫자만 '***'로 가린다. 접미사·문장 구조는 유지.
|
||||
export function maskPrices(text: string): string {
|
||||
return text
|
||||
.replace(/₩\s?[0-9][0-9,]*/g, "₩***")
|
||||
.replace(/[0-9][0-9,]*\s*(?=원)/g, "***")
|
||||
.replace(/[0-9]+(?:\.[0-9]+)?\s*(?=%)/g, "***")
|
||||
}
|
||||
|
||||
@ -69,7 +69,7 @@ export default function CardsPage() {
|
||||
<div>
|
||||
<Typography variant="h3" className="text-xs">협상카드 및 와일드카드 관리</Typography>
|
||||
<Typography variant="muted" className="text-[10px] mt-0.5">
|
||||
본인 카드와 전체(공용) 카드를 함께 조회·관리합니다. 전체 카드는 모든 사용자에게 공유됩니다.
|
||||
우리 회사 카드와 기본 제공(공용) 카드를 함께 조회합니다. 공용 카드는 수정·삭제할 수 없습니다.
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -22,7 +22,8 @@ export type Partner = SupplierData & {
|
||||
export interface NegotiationCard {
|
||||
id: string;
|
||||
isWildcard: boolean;
|
||||
isShared: boolean; // 전체(공용, user_id NULL) 카드 여부 — 개인 카드는 false
|
||||
isShared: boolean; // 전체(공용, user_id NULL) 카드 여부 — 공용은 수정·삭제 불가(기본 제공 자산)
|
||||
userId?: string; // 등록자 user_id. 수정·삭제 게이팅(본인∪최고관리자) 판정용. 공용 카드는 없음
|
||||
usageType: number; // usage_type(CardUsageType): 1=공통 2=신규견적전용 3=재견적전용
|
||||
code: string;
|
||||
title: string;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user