[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:
Mina Choi 2026-07-09 17:04:40 +09:00
parent 0d62440ee1
commit 1b9ca062f3
20 changed files with 331 additions and 168 deletions

View File

@ -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

View File

@ -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)

View File

@ -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))
)

View File

@ -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

View File

@ -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

View File

@ -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−값)' 표시용)

View File

@ -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)

View File

@ -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

View File

@ -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>

View File

@ -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: '특정 원재료 포함 입찰에만 적용' },
],
);
}

View File

@ -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>

View File

@ -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: '카드번호',

View File

@ -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();
};

View File

@ -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 ?? '',

View File

@ -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);
}}
>

View File

@ -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>

View File

@ -83,7 +83,7 @@ export function TargetPriceModal({
</Typography>
{bd.is_inherited && (
<Typography as="p" variant="small" className="text-[10px] text-amber-600">
* 저장된 목표가가 현재 후보 최소값과 다름 — 재생성 상속 또는 산정 후 상품·세팅 변경(아래 후보는 현재값 기준 참고용)
* 저장된 목표가와 일치하는 현재 후보 없음 — 재생성 상속 또는 산정 후 상품·세팅 변경(아래 후보는 현재값 기준 참고용)
</Typography>
)}
</div>

View File

@ -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, "***")
}

View File

@ -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>

View File

@ -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;