From 1b9ca062f3f488be63f62759f4b260e87a1ce9ba Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Thu, 9 Jul 2026 17:04:40 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20negodata:=20=ED=98=91=EC=83=81?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=ED=9A=8C=EC=82=AC=20=EC=8A=A4=EC=BD=94?= =?UTF-8?q?=ED=94=84=C2=B7=EA=B3=B5=EC=9A=A9=20=EC=9D=BD=EA=B8=B0=EC=A0=84?= =?UTF-8?q?=EC=9A=A9=20+=20=EA=B2=AC=EC=A0=81=20=EB=AA=A9=ED=91=9C?= =?UTF-8?q?=EA=B0=80=20=EC=82=B0=EC=A0=95=EB=82=B4=EC=97=AD=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 협상카드 - 목록/조회를 등록자 개인 → 회사(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 --- negodata/backend/crud/card_crud.py | 25 +++- negodata/backend/crud/quotation_crud.py | 5 +- negodata/backend/router/v1/card/card.py | 14 ++- negodata/backend/router/v1/card/protocol.py | 2 +- negodata/backend/services/card_service.py | 105 +++++++++++------ .../backend/services/quotation_service.py | 13 +- negodata/backend/tests/test_card.py | 111 +++++++++++++----- .../backend/tests/test_quotation_create.py | 8 +- negodata/front/src/components/ui/combobox.tsx | 10 +- .../cards/components/CardExcelUploadModal.tsx | 19 +-- .../cards/components/CardFormSheet.tsx | 54 ++++----- .../features/cards/components/CardTable.tsx | 28 ++--- .../src/features/cards/hooks/useCards.ts | 11 +- negodata/front/src/features/cards/types.ts | 1 + .../components/QuotationCreateModal.tsx | 79 ++++++++++++- .../QuotationCardsTab.tsx | 4 +- .../QuotationDetailSheet/TargetPriceModal.tsx | 2 +- negodata/front/src/lib/utils.ts | 3 +- negodata/front/src/pages/cards.tsx | 2 +- negodata/front/src/types.ts | 3 +- 20 files changed, 331 insertions(+), 168 deletions(-) diff --git a/negodata/backend/crud/card_crud.py b/negodata/backend/crud/card_crud.py index 729df96..dfce1da 100644 --- a/negodata/backend/crud/card_crud.py +++ b/negodata/backend/crud/card_crud.py @@ -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 diff --git a/negodata/backend/crud/quotation_crud.py b/negodata/backend/crud/quotation_crud.py index 17caad6..4a9a011 100644 --- a/negodata/backend/crud/quotation_crud.py +++ b/negodata/backend/crud/quotation_crud.py @@ -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) diff --git a/negodata/backend/router/v1/card/card.py b/negodata/backend/router/v1/card/card.py index 87f7e55..386244a 100644 --- a/negodata/backend/router/v1/card/card.py +++ b/negodata/backend/router/v1/card/card.py @@ -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)) + ) diff --git a/negodata/backend/router/v1/card/protocol.py b/negodata/backend/router/v1/card/protocol.py index 5740813..820019c 100644 --- a/negodata/backend/router/v1/card/protocol.py +++ b/negodata/backend/router/v1/card/protocol.py @@ -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 diff --git a/negodata/backend/services/card_service.py b/negodata/backend/services/card_service.py index d4c546e..9e6c845 100644 --- a/negodata/backend/services/card_service.py +++ b/negodata/backend/services/card_service.py @@ -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 diff --git a/negodata/backend/services/quotation_service.py b/negodata/backend/services/quotation_service.py index 34fec7f..afd947f 100644 --- a/negodata/backend/services/quotation_service.py +++ b/negodata/backend/services/quotation_service.py @@ -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−값)' 표시용) diff --git a/negodata/backend/tests/test_card.py b/negodata/backend/tests/test_card.py index 2445001..c506894 100644 --- a/negodata/backend/tests/test_card.py +++ b/negodata/backend/tests/test_card.py @@ -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) diff --git a/negodata/backend/tests/test_quotation_create.py b/negodata/backend/tests/test_quotation_create.py index 9c192d4..b10b6ac 100644 --- a/negodata/backend/tests/test_quotation_create.py +++ b/negodata/backend/tests/test_quotation_create.py @@ -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 diff --git a/negodata/front/src/components/ui/combobox.tsx b/negodata/front/src/components/ui/combobox.tsx index a2c84dd..7e18d5c 100644 --- a/negodata/front/src/components/ui/combobox.tsx +++ b/negodata/front/src/components/ui/combobox.tsx @@ -57,6 +57,7 @@ export function Combobox({ const [text, setText] = useState(''); const [open, setOpen] = useState(false); const rootRef = useRef(null); + const popoverRef = useRef(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({ {open && ( -
+
{searchInput} {list}
diff --git a/negodata/front/src/features/cards/components/CardExcelUploadModal.tsx b/negodata/front/src/features/cards/components/CardExcelUploadModal.tsx index 1376622..20a1b36 100644 --- a/negodata/front/src/features/cards/components/CardExcelUploadModal.tsx +++ b/negodata/front/src/features/cards/components/CardExcelUploadModal.tsx @@ -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): 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 카드이름 * 스크립트 * 용도 - 공개 @@ -304,9 +297,6 @@ export function CardExcelUploadModal({ open, onConfirm, onClose }: CardExcelUplo {usageCode(row.usage) === CardUsageType.NEW ? '신규' : usageCode(row.usage) === CardUsageType.REUSE ? '재' : '공통'} - - {isShared(row.scope) ? '전체' : '개인'} - ))} @@ -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: '특정 원재료 포함 입찰에만 적용' }, ], ); } diff --git a/negodata/front/src/features/cards/components/CardFormSheet.tsx b/negodata/front/src/features/cards/components/CardFormSheet.tsx index 14e9f10..88924c3 100644 --- a/negodata/front/src/features/cards/components/CardFormSheet.tsx +++ b/negodata/front/src/features/cards/components/CardFormSheet.tsx @@ -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({
- {/* 공개 범위(scope): 개인=나만 / 전체=모두 공용(user_id NULL). 등록 시에만 결정, 수정 시 고정 */} -
- 공개 범위 - ( - - )} - /> -
- {/* 카드 용도(usage_type): 공통 / 신규전용 / 재전용 */}
카드 용도 @@ -354,13 +332,15 @@ export function CardFormSheet({
)} - {/* Footer — 다른 폼 sheet와 동일: 좌측 삭제(edit), 우측 취소/저장 */} + {/* Footer — 다른 폼 sheet와 동일: 좌측 삭제(edit), 우측 취소/저장. 권한 없으면 비활성(툴팁 사유) */}
{mode === 'edit' && card && ( -
diff --git a/negodata/front/src/features/cards/components/CardTable.tsx b/negodata/front/src/features/cards/components/CardTable.tsx index 1fa930a..a989920 100644 --- a/negodata/front/src/features/cards/components/CardTable.tsx +++ b/negodata/front/src/features/cards/components/CardTable.tsx @@ -21,24 +21,16 @@ export function CardTable({ data, onEdit, footer }: CardTableProps) { { header: '구분', headClassName: 'w-24', - cell: (card) => ( -
- {card.isWildcard ? ( - - 와일드카드 - - ) : ( - - 협상카드 - - )} - {card.isShared && ( - - 전체 - - )} -
- ), + cell: (card) => + card.isWildcard ? ( + + 와일드카드 + + ) : ( + + 협상카드 + + ), }, { header: '카드번호', diff --git a/negodata/front/src/features/cards/hooks/useCards.ts b/negodata/front/src/features/cards/hooks/useCards.ts index 97db251..666d87b 100644 --- a/negodata/front/src/features/cards/hooks/useCards.ts +++ b/negodata/front/src/features/cards/hooks/useCards.ts @@ -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(); }; diff --git a/negodata/front/src/features/cards/types.ts b/negodata/front/src/features/cards/types.ts index 1f78abb..893b27b 100644 --- a/negodata/front/src/features/cards/types.ts +++ b/negodata/front/src/features/cards/types.ts @@ -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 ?? '', diff --git a/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx b/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx index f1607f3..4997939 100644 --- a/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx +++ b/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx @@ -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([]); - const [dueDate, setDueDate] = useState(nowKstLocalInput); + const [dueDate, setDueDate] = useState(defaultDueDateLocalInput); const [settingId, setSettingId] = useState(quotationSettings[0]?.qt_setting_id ?? ''); const [selectedCardIds, setSelectedCardIds] = useState([]); // 선택 항목 표시데이터 캐시 — 담는 순간 이름/이메일·카드메타를 적재해, 검색어가 바뀌어 콤보 목록에서 빠져도 아래 선택 테이블이 유지되게 한다. @@ -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)} />
@@ -382,11 +427,29 @@ export function QuotationCreateModal({ ))} +
+ {[ + { label: '상품단가', value: unitPrice }, + { label: '목표가 상한', value: targetPriceLimit }, + ].map((r) => ( +
+ {r.label} + + {r.value != null ? `₩${Number(r.value).toLocaleString()}` : '-'} + +
+ ))} +
{!targetReady && ( ⚠ MD 제시가도 없고 상품에 산정할 값도 없습니다 — MD가를 입력하거나 위 ‘상품 상세에서 수정’으로 값을 채워야 목표가가 나옵니다. )} + {targetLimitExceeded && ( + + 목표가 예상값 ₩{estimatedTargetPrice?.toLocaleString()}이 상품단가의 2배를 초과합니다 — MD 제시가 또는 상품 가격 정보를 조정해 주세요. + + )} )} @@ -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); }} > diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/QuotationCardsTab.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/QuotationCardsTab.tsx index 4d4b082..44e1a3b 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/QuotationCardsTab.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/QuotationCardsTab.tsx @@ -13,7 +13,6 @@ export function QuotationCardsTab({ quotationCardViews }: { quotationCardViews: - 세션 카드 ID 카드 이름 타입 @@ -22,7 +21,6 @@ export function QuotationCardsTab({ quotationCardViews }: { quotationCardViews: {quotationCardViews.length > 0 ? ( quotationCardViews.map((qc) => ( - {qc.session_card_id} {qc.card_id ? ( - + 사용된 협상 카드가 없습니다. (리스트가 비어 있습니다) diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/TargetPriceModal.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/TargetPriceModal.tsx index 653f916..7873c68 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/TargetPriceModal.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/TargetPriceModal.tsx @@ -83,7 +83,7 @@ export function TargetPriceModal({ {bd.is_inherited && ( - * 저장된 목표가가 현재 후보 최소값과 다름 — 재생성 상속 또는 산정 후 상품·세팅 변경(아래 후보는 현재값 기준 참고용) + * 저장된 목표가와 일치하는 현재 후보 없음 — 재생성 상속 또는 산정 후 상품·세팅 변경(아래 후보는 현재값 기준 참고용) )} diff --git a/negodata/front/src/lib/utils.ts b/negodata/front/src/lib/utils.ts index e433c61..baaef9f 100644 --- a/negodata/front/src/lib/utils.ts +++ b/negodata/front/src/lib/utils.ts @@ -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, "***") } diff --git a/negodata/front/src/pages/cards.tsx b/negodata/front/src/pages/cards.tsx index 62f767d..40f3c4a 100644 --- a/negodata/front/src/pages/cards.tsx +++ b/negodata/front/src/pages/cards.tsx @@ -69,7 +69,7 @@ export default function CardsPage() {
협상카드 및 와일드카드 관리 - 본인 카드와 전체(공용) 카드를 함께 조회·관리합니다. 전체 카드는 모든 사용자에게 공유됩니다. + 우리 회사 카드와 기본 제공(공용) 카드를 함께 조회합니다. 공용 카드는 수정·삭제할 수 없습니다.
diff --git a/negodata/front/src/types.ts b/negodata/front/src/types.ts index 4feac1b..710dfeb 100644 --- a/negodata/front/src/types.ts +++ b/negodata/front/src/types.ts @@ -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;