From c62b66e35b212fd6c7ba417de4c219d0d8f394e9 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Tue, 7 Jul 2026 16:18:33 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20negodata:=20=EC=86=8C=EC=9C=A0?= =?UTF-8?q?=EA=B6=8C=20=EA=B2=8C=EC=9D=B4=ED=8C=85=20+=20=EA=B2=AC?= =?UTF-8?q?=EC=A0=81=20=EB=82=99=EC=B0=B0=20+=20=EC=9E=91=EC=84=B1?= =?UTF-8?q?=EC=9E=90=EB=AA=85=20=ED=91=9C=EC=8B=9C=20+=20=EC=A0=84?= =?UTF-8?q?=ED=99=94=EC=9E=85=EB=A0=A5=20+=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=EC=97=91=EC=85=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 소유권 게이팅(common/authz): 변경 액션 본인∪OWNER, 협력사 삭제 OWNER 전용 - 견적 수동 낙찰(award) + 작성자명(creatorName) 표시 + 전화번호 입력 컴포넌트 + 카드 엑셀 업로드 - supplier_type 은 이번 커밋 미변경(다음 커밋에서 코드부터 정리 예정) Co-Authored-By: Claude Opus 4.8 --- negodata/backend/common/authz.py | 9 + negodata/backend/crud/card_crud.py | 26 +- negodata/backend/crud/item_crud.py | 20 +- negodata/backend/crud/quotation_crud.py | 36 +- negodata/backend/crud/supplier_crud.py | 20 +- negodata/backend/router/v1/card/protocol.py | 3 + negodata/backend/router/v1/item/item.py | 4 +- negodata/backend/router/v1/item/protocol.py | 1 + .../backend/router/v1/quotation/protocol.py | 4 + .../backend/router/v1/quotation/quotation.py | 21 +- .../backend/router/v1/supplier/protocol.py | 1 + .../backend/router/v1/supplier/supplier.py | 7 +- negodata/backend/services/card_service.py | 37 +- negodata/backend/services/item_service.py | 42 +- .../backend/services/quotation_service.py | 115 +++++- negodata/backend/services/supplier_service.py | 17 + negodata/backend/tests/test_card.py | 65 +++ .../backend/tests/test_ownership_gating.py | 122 ++++++ .../backend/tests/test_quotation_award.py | 206 ++++++++++ .../front/src/api/generated/model/cardData.ts | 3 + .../generated/model/cardDataCreatorName.ts | 8 + .../front/src/api/generated/model/itemData.ts | 2 + .../generated/model/itemDataCreatorName.ts | 8 + .../api/generated/model/reqAwardQuotation.ts | 10 + .../src/api/generated/model/reqCreateCard.ts | 1 + .../src/api/generated/model/supplierData.ts | 2 + .../model/supplierDataCreatorName.ts | 8 + .../src/api/generated/quotation/quotation.ts | 66 ++++ .../front/src/components/ui/phone-input.tsx | 26 ++ .../features/auth/components/ProfileSheet.tsx | 22 +- .../cards/components/CardExcelUploadModal.tsx | 372 ++++++++++++++++++ .../cards/components/CardFormSheet.tsx | 86 +++- .../features/cards/components/CardTable.tsx | 35 +- .../src/features/cards/hooks/useCards.ts | 20 + negodata/front/src/features/cards/types.ts | 2 + .../members/components/MemberFormSheet.tsx | 23 +- .../members/components/MemberTable.tsx | 3 +- .../partners/components/ExcelUploadModal.tsx | 8 +- .../partners/components/PartnerFormSheet.tsx | 42 +- .../partners/components/PartnerTable.tsx | 9 +- .../products/components/ExcelUploadModal.tsx | 6 +- .../products/components/PriceUpdateModal.tsx | 6 +- .../products/components/ProductFormSheet.tsx | 25 +- .../products/components/ProductTable.tsx | 6 + .../components/QuotationCreateModal.tsx | 209 +++++++--- .../DrawerHeaderCards.tsx | 12 + .../QuotationDetailSheet/RegenerateModal.tsx | 6 +- .../SessionsStatusTab.tsx | 118 +++++- .../QuotationDetailSheet/TargetPriceModal.tsx | 6 +- .../components/QuotationDetailSheet/index.tsx | 51 ++- .../components/QuotationSettingsModal.tsx | 6 +- .../quotations/hooks/useQuotations.ts | 28 +- negodata/front/src/lib/phone.ts | 34 ++ negodata/front/src/pages/cards.tsx | 58 ++- negodata/front/src/pages/notifications.tsx | 3 +- negodata/front/src/pages/quotation.tsx | 2 + 56 files changed, 1900 insertions(+), 188 deletions(-) create mode 100644 negodata/backend/common/authz.py create mode 100644 negodata/backend/tests/test_card.py create mode 100644 negodata/backend/tests/test_ownership_gating.py create mode 100644 negodata/backend/tests/test_quotation_award.py create mode 100644 negodata/front/src/api/generated/model/cardDataCreatorName.ts create mode 100644 negodata/front/src/api/generated/model/itemDataCreatorName.ts create mode 100644 negodata/front/src/api/generated/model/reqAwardQuotation.ts create mode 100644 negodata/front/src/api/generated/model/supplierDataCreatorName.ts create mode 100644 negodata/front/src/components/ui/phone-input.tsx create mode 100644 negodata/front/src/features/cards/components/CardExcelUploadModal.tsx create mode 100644 negodata/front/src/lib/phone.ts diff --git a/negodata/backend/common/authz.py b/negodata/backend/common/authz.py new file mode 100644 index 0000000..50b0ee3 --- /dev/null +++ b/negodata/backend/common/authz.py @@ -0,0 +1,9 @@ +from common.enums import UserRole + + +def is_owner_or_admin(resource_user_id, user_id, role) -> bool: + """변경 액션 공용 소유권 판정 — 리소스 소유자(user_id 일치) 또는 최고관리자(OWNER)면 True. + + 프론트의 버튼 게이팅과 같은 규칙을 백엔드에서 강제하는 단일 출처. + 소유자 없는 공용 리소스(예: user_id NULL 공용카드)는 이 판정 대상이 아니다(도메인별 별도 처리).""" + return str(resource_user_id) == str(user_id) or role == UserRole.OWNER.value diff --git a/negodata/backend/crud/card_crud.py b/negodata/backend/crud/card_crud.py index c05019f..729df96 100644 --- a/negodata/backend/crud/card_crud.py +++ b/negodata/backend/crud/card_crud.py @@ -5,6 +5,7 @@ from sqlalchemy import select, func, and_, or_, update from sqlalchemy.ext.asyncio import AsyncSession from common.database.db_session_manager import DB_SESSION_MNG +from common.database.model.models import users from common.enums import ErrorType from common.logger import LOG from common.utils.gtime import GTime @@ -32,13 +33,21 @@ class ICardCRUD(ABC): async def soft_delete(self, cdb: AsyncSession, model, pk_col, card_id) -> ErrorType: pass + @abstractmethod + async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]: + pass + class CardCRUD(ICardCRUD): async def search( self, cdb: AsyncSession, model, user_id, search: Optional[str], skip: int, limit: int ) -> Tuple[ErrorType, list, int]: try: - conditions = [model.deleted == False, model.user_id == user_id] # noqa: E712 + # 내 개인 카드 + 전체(공용, user_id NULL) 카드. 남의 개인 카드는 제외. + conditions = [ + model.deleted == False, # noqa: E712 + or_(model.user_id == user_id, model.user_id.is_(None)), + ] if search: conditions.append( or_( @@ -102,3 +111,18 @@ class CardCRUD(ICardCRUD): except Exception as ex: LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED + + async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]: + """user_id 목록 → {user_id: name}. 카드 목록 '작성자(등록자)' 표기용(company.users 조인). + 공용(user_id NULL) 카드는 호출 전에 걸러 넘긴다 → 맵에 없으면 작성자 없음.""" + try: + if not user_ids: + return ErrorType.SUCCESS, {} + query = select(users.user_id, users.name).where(users.user_id.in_(user_ids)) + err_type, rows = await DB_SESSION_MNG.execute(cdb, query) + if err_type != ErrorType.SUCCESS: + return err_type, {} + return ErrorType.SUCCESS, {uid: name for uid, name in rows} + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, {} diff --git a/negodata/backend/crud/item_crud.py b/negodata/backend/crud/item_crud.py index 0995d08..622d0b6 100644 --- a/negodata/backend/crud/item_crud.py +++ b/negodata/backend/crud/item_crud.py @@ -5,7 +5,7 @@ from sqlalchemy import select, func, and_, or_, update from sqlalchemy.ext.asyncio import AsyncSession from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import items +from common.database.model.models import items, users from common.enums import ErrorType from common.logger import LOG from common.utils.gtime import GTime @@ -45,6 +45,10 @@ class IItemCRUD(ABC): async def soft_delete(self, cdb: AsyncSession, item_id) -> ErrorType: pass + @abstractmethod + async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]: + pass + class ItemCRUD(IItemCRUD): async def search( @@ -166,3 +170,17 @@ class ItemCRUD(IItemCRUD): except Exception as ex: LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED + + async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]: + """user_id 목록 → {user_id: name}. 상품 목록/상세 '등록자(작성자)' 표기용(company.users 조인).""" + try: + if not user_ids: + return ErrorType.SUCCESS, {} + query = select(users.user_id, users.name).where(users.user_id.in_(user_ids)) + err_type, rows = await DB_SESSION_MNG.execute(cdb, query) + if err_type != ErrorType.SUCCESS: + return err_type, {} + return ErrorType.SUCCESS, {uid: name for uid, name in rows} + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, {} diff --git a/negodata/backend/crud/quotation_crud.py b/negodata/backend/crud/quotation_crud.py index 4ce3ec8..3a8c6b8 100644 --- a/negodata/backend/crud/quotation_crud.py +++ b/negodata/backend/crud/quotation_crud.py @@ -10,7 +10,7 @@ from common.database.model.models import ( quotations, sessions, chats, nego_cards, wild_cards, items, suppliers, quotation_settings, version_nego_cards, version_wild_cards, users, ) -from common.enums import ErrorType, QuotationStatus, SessionStatus +from common.enums import CloseReason, ErrorType, QuotationStatus, SessionStatus from common.logger import LOG from common.utils.gtime import GTime @@ -148,6 +148,10 @@ class IQuotationCRUD(ABC): async def claim_for_close(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, int]: pass + @abstractmethod + async def claim_for_award(self, cdb: AsyncSession, qt_id, supplier_id, supplier_name) -> Tuple[ErrorType, int]: + pass + class QuotationCRUD(IQuotationCRUD): async def search( @@ -474,6 +478,36 @@ class QuotationCRUD(IQuotationCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, 0 + async def claim_for_award(self, cdb: AsyncSession, qt_id, supplier_id, supplier_name) -> Tuple[ErrorType, int]: + """[동시 직접낙찰 가드] 개찰(마감·낙찰자 미정, close_reason ∈ OPEN_*)인 견적만 낙찰(AWARDED)로 선점 전이. + 선택 협력사를 낙찰자(preferred_sp_*)로 박고 동가 플래그는 내린다. status 는 이미 CLOSED 라 유지. + 반환: (ErrorType, 적용행수). 이미 낙찰됐거나(재클릭) 개찰이 아니면 0 → 서비스가 한 번만 통과시킨다.""" + try: + query = ( + update(quotations) + .where( + quotations.qt_id == qt_id, + quotations.status == QuotationStatus.CLOSED.value, + quotations.close_reason.in_( + [CloseReason.OPEN_PRICE.value, CloseReason.OPEN_EQUAL.value, + CloseReason.OPEN_NOSHOW.value, CloseReason.OPEN_REJECT.value] + ), + quotations.deleted == False, # noqa: E712 + ) + .values( + close_reason=CloseReason.AWARDED.value, + preferred_sp_yn=True, + preferred_sp_id=supplier_id, + preferred_sp_name=supplier_name, + equal_bid_yn=False, + updated_at=GTime.UTC(), + ) + ) + return await DB_SESSION_MNG.add_with_rowcount(cdb, query) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, 0 + async def update_sessions_status(self, cdb: AsyncSession, qt_id, from_statuses: list[int], to_status: int) -> ErrorType: # 견적에 딸린 세션 중 from_statuses 에 속한 것만 to_status 로 일괄 전이(삭제 제외). 다른 상태는 건드리지 않는다. try: diff --git a/negodata/backend/crud/supplier_crud.py b/negodata/backend/crud/supplier_crud.py index 84e54a5..365b230 100644 --- a/negodata/backend/crud/supplier_crud.py +++ b/negodata/backend/crud/supplier_crud.py @@ -5,7 +5,7 @@ from sqlalchemy import select, func, and_, or_, update from sqlalchemy.ext.asyncio import AsyncSession from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import suppliers +from common.database.model.models import suppliers, users from common.enums import ErrorType from common.logger import LOG from common.utils.gtime import GTime @@ -41,6 +41,10 @@ class ISupplierCRUD(ABC): async def soft_delete(self, cdb: AsyncSession, supplier_id) -> ErrorType: pass + @abstractmethod + async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]: + pass + class SupplierCRUD(ISupplierCRUD): async def search( @@ -144,3 +148,17 @@ class SupplierCRUD(ISupplierCRUD): except Exception as ex: LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED + + async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]: + """user_id 목록 → {user_id: name}. 협력사 목록/상세 '등록자(작성자)' 표기용(company.users 조인).""" + try: + if not user_ids: + return ErrorType.SUCCESS, {} + query = select(users.user_id, users.name).where(users.user_id.in_(user_ids)) + err_type, rows = await DB_SESSION_MNG.execute(cdb, query) + if err_type != ErrorType.SUCCESS: + return err_type, {} + return ErrorType.SUCCESS, {uid: name for uid, name in rows} + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, {} diff --git a/negodata/backend/router/v1/card/protocol.py b/negodata/backend/router/v1/card/protocol.py index bd54f39..5740813 100644 --- a/negodata/backend/router/v1/card/protocol.py +++ b/negodata/backend/router/v1/card/protocol.py @@ -14,6 +14,7 @@ class CardProtocol(WebPacketProtocol): class Req_CreateCard(CardProtocol): is_wildcard: bool = False + is_shared: bool = False # True=전체(공용, user_id NULL 저장) / False=개인(등록 유저 소유) name: Optional[str] = None number: Optional[str] = None script: Optional[str] = None @@ -42,6 +43,8 @@ class CardData(WebPacketProtocol): nego_card_id: uuid.UUID # 통합 식별자(일반=nego_card_id / 와일드=wild_card_id) user_id: Optional[uuid.UUID] = None is_wildcard: bool = False + is_shared: bool = False # 전체(공용) 카드 여부 = user_id NULL. 목록 배지/폼 스코프 표시용 + creator_name: Optional[str] = None # 작성자(등록자) 이름. user_id→company.users.name 조인. 공용 카드는 None name: Optional[str] = None number: Optional[str] = None script: Optional[str] = None diff --git a/negodata/backend/router/v1/item/item.py b/negodata/backend/router/v1/item/item.py index 2e21d81..b423a88 100644 --- a/negodata/backend/router/v1/item/item.py +++ b/negodata/backend/router/v1/item/item.py @@ -69,12 +69,12 @@ async def get_item(item_id: UUID, service: ItemService = Depends(), user_info: U async def update_item( item_id: UUID, req: Req_UpdateItem, service: ItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken) ): - return RemoveNoneResponse(await service.update_item(user_info.company_id, str(item_id), req)) + return RemoveNoneResponse(await service.update_item(user_info.company_id, str(item_id), req, user_info.user_id, user_info.role)) @router.delete(path="/delete/{item_id}", response_model=Res_DeleteItem, summary="상품 삭제") async def delete_item(item_id: UUID, service: ItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): - return RemoveNoneResponse(await service.delete_item(user_info.company_id, str(item_id))) + return RemoveNoneResponse(await service.delete_item(user_info.company_id, str(item_id), user_info.user_id, user_info.role)) @router.post(path="/{item_id}/lowest-price", response_model=Res_LowestPriceTrigger, summary="최저가 수집 요청(스텁)") diff --git a/negodata/backend/router/v1/item/protocol.py b/negodata/backend/router/v1/item/protocol.py index fd8d7e6..fa145a9 100644 --- a/negodata/backend/router/v1/item/protocol.py +++ b/negodata/backend/router/v1/item/protocol.py @@ -64,6 +64,7 @@ class ItemData(WebPacketProtocol): item_id: uuid.UUID company_id: uuid.UUID user_id: uuid.UUID + creator_name: Optional[str] = None # 등록자(작성자) 이름. user_id→company.users.name 조인 name: str code: Optional[str] = None category: Optional[str] = None diff --git a/negodata/backend/router/v1/quotation/protocol.py b/negodata/backend/router/v1/quotation/protocol.py index e7cb321..d15aa9a 100644 --- a/negodata/backend/router/v1/quotation/protocol.py +++ b/negodata/backend/router/v1/quotation/protocol.py @@ -40,6 +40,10 @@ class Req_RegenerateQuotation(QuotationProtocol): supplier_ids: list[uuid.UUID] = [] # 다음 라운드에 부를 공급사(프론트 선택). 상품·기간·번호는 원 견적에서 이어받음 +class Req_AwardQuotation(QuotationProtocol): + winner_supplier_id: uuid.UUID # 담당자가 직접 낙찰시킬 협력사(투찰한 협상완료 세션 중 선택) + + class QuotationData(WebPacketProtocol): model_config = ConfigDict(from_attributes=True) diff --git a/negodata/backend/router/v1/quotation/quotation.py b/negodata/backend/router/v1/quotation/quotation.py index ec1326f..b853984 100644 --- a/negodata/backend/router/v1/quotation/quotation.py +++ b/negodata/backend/router/v1/quotation/quotation.py @@ -7,6 +7,7 @@ from common.models.gmodel import PageParams, UserInfo from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse from services.quotation_service import QuotationService from .protocol import ( + Req_AwardQuotation, Req_CreateQuotation, Req_RegenerateQuotation, Res_CreateQuotation, @@ -55,14 +56,24 @@ async def create_quotation( @router.post(path="/stop/{qt_id}", response_model=Res_Quotation, summary="견적 마감") async def stop_quotation(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): - return RemoveNoneResponse(await service.stop_quotation(str(qt_id), user_info.company_id)) + return RemoveNoneResponse(await service.stop_quotation(str(qt_id), user_info.company_id, user_info.user_id, user_info.role)) + + +@router.post(path="/award/{qt_id}", response_model=Res_Quotation, summary="개찰 견적 직접 낙찰") +async def award_quotation( + qt_id: UUID, req: Req_AwardQuotation, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken) +): + # 권한(본인 견적만/OWNER 예외)은 서비스에서 판정하도록 호출자 user_id·role 을 넘긴다. + return RemoveNoneResponse( + await service.award_quotation(str(qt_id), user_info.company_id, user_info.user_id, user_info.role, req.winner_supplier_id) + ) @router.post(path="/regenerate/{qt_id}", response_model=Res_CreateQuotation, summary="견적 재생성(다음 라운드)") async def regenerate_quotation( qt_id: UUID, req: Req_RegenerateQuotation, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken) ): - return RemoveNoneResponse(await service.regenerate_quotation(str(qt_id), user_info.company_id, req.supplier_ids)) + return RemoveNoneResponse(await service.regenerate_quotation(str(qt_id), user_info.company_id, req.supplier_ids, user_info.user_id, user_info.role)) # ----- 견적 상세 (FK로 연결된 하위 데이터 / 일부는 모델 미존재로 스텁) ----- @@ -78,7 +89,7 @@ async def get_quotation_sessions(qt_id: UUID, service: QuotationService = Depend @router.post(path="/{qt_id}/notify", response_model=Res_NotifySessions, summary="협상 초청 메일 발송") async def notify_quotation(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): - return RemoveNoneResponse(await service.notify_sessions(str(qt_id), user_info.company_id)) + return RemoveNoneResponse(await service.notify_sessions(str(qt_id), user_info.company_id, user_info.user_id, user_info.role)) @router.get(path="/session/{session_id}/chat", response_model=Res_SessionChat, summary="채팅 상세") @@ -93,7 +104,7 @@ async def get_target_breakdown(session_id: UUID, service: QuotationService = Dep @router.post(path="/session/{session_id}/notify", response_model=Res_NotifySessions, summary="세션 초청 메일 재발송") async def notify_session(session_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): - return RemoveNoneResponse(await service.notify_session(str(session_id), user_info.company_id)) + return RemoveNoneResponse(await service.notify_session(str(session_id), user_info.company_id, user_info.user_id, user_info.role)) @router.get(path="/{qt_id}/result", response_model=Res_QuotationResult, summary="낙찰 결과") @@ -108,7 +119,7 @@ async def get_quotation_cards(qt_id: UUID, service: QuotationService = Depends() @router.delete(path="/delete/{qt_id}", response_model=Res_DeleteQuotation, summary="견적 삭제") async def delete_quotation(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): - return RemoveNoneResponse(await service.delete_quotation(str(qt_id), user_info.company_id)) + return RemoveNoneResponse(await service.delete_quotation(str(qt_id), user_info.company_id, user_info.user_id, user_info.role)) @router.get(path="/supplier/{supplier_id}/last-type", response_model=Res_LastSupplierType, summary="협력사 직전 견적 유형") diff --git a/negodata/backend/router/v1/supplier/protocol.py b/negodata/backend/router/v1/supplier/protocol.py index 5340c74..7e85216 100644 --- a/negodata/backend/router/v1/supplier/protocol.py +++ b/negodata/backend/router/v1/supplier/protocol.py @@ -35,6 +35,7 @@ class SupplierData(WebPacketProtocol): supplier_id: uuid.UUID company_id: uuid.UUID user_id: uuid.UUID + creator_name: Optional[str] = None # 등록자(작성자) 이름. user_id→company.users.name 조인 name: str code: Optional[str] = None manager_name: Optional[str] = None diff --git a/negodata/backend/router/v1/supplier/supplier.py b/negodata/backend/router/v1/supplier/supplier.py index d1d81a9..7925879 100644 --- a/negodata/backend/router/v1/supplier/supplier.py +++ b/negodata/backend/router/v1/supplier/supplier.py @@ -3,7 +3,7 @@ from uuid import UUID from fastapi import APIRouter, Depends, Query from common.models.gmodel import PageParams, UserInfo -from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse +from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse, RequireOwner from services.supplier_service import SupplierService from .protocol import ( Req_CheckCodes, @@ -59,6 +59,7 @@ async def update_supplier( ) -@router.delete(path="/delete/{supplier_id}", response_model=Res_DeleteSupplier, summary="협력사 삭제") -async def delete_supplier(supplier_id: UUID, service: SupplierService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): +@router.delete(path="/delete/{supplier_id}", response_model=Res_DeleteSupplier, summary="협력사 삭제 (최고관리자 전용)") +async def delete_supplier(supplier_id: UUID, service: SupplierService = Depends(), user_info: UserInfo = Depends(RequireOwner)): + # 협력사 명부는 회사 공유 자원 — 파괴적 삭제는 최고관리자만(RequireOwner 가 비-OWNER 를 403 차단). return RemoveNoneResponse(await service.delete_supplier(user_info.company_id, str(supplier_id))) diff --git a/negodata/backend/services/card_service.py b/negodata/backend/services/card_service.py index 513086d..26cd65b 100644 --- a/negodata/backend/services/card_service.py +++ b/negodata/backend/services/card_service.py @@ -25,6 +25,7 @@ class CardService: nego_card_id=row.nego_card_id, user_id=row.user_id, is_wildcard=False, + is_shared=row.user_id is None, name=row.name, number=row.number, script=row.script, @@ -42,6 +43,7 @@ class CardService: nego_card_id=row.wild_card_id, user_id=row.user_id, is_wildcard=True, + is_shared=row.user_id is None, name=row.name, number=row.number, script=row.script, @@ -56,7 +58,8 @@ class CardService: # ---- 소유 카드 탐색(어느 테이블인지 모를 때) ------------------------------ async def _find_owned(self, user_uuid: uuid.UUID, card_id: uuid.UUID): - """card_id 를 nego_cards → wild_cards 순으로 찾고 소유권 확인. + """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(), @@ -64,7 +67,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 != user_uuid: + if row.user_id is not None and row.user_id != user_uuid: return ErrorType.CARD_NOT_FOUND, None, None, None, False return ErrorType.SUCCESS, nego_cards, nego_cards.nego_card_id, row, False @@ -74,7 +77,7 @@ 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 != user_uuid: + if row.user_id is not None and row.user_id != user_uuid: return ErrorType.CARD_NOT_FOUND, None, None, None, True return ErrorType.SUCCESS, wild_cards, wild_cards.wild_card_id, row, True @@ -114,7 +117,21 @@ class CardService: merged = [self._nego_to_data(r) for r in nego_rows] + [self._wild_to_data(r) for r in wild_rows] merged.sort(key=lambda c: c.created_at or "", reverse=True) - res.cards = merged[pg.skip : pg.skip + pg.size] + page = merged[pg.skip : pg.skip + pg.size] + + # 작성자명 배치 조인 — 페이지 카드의 작성자 id를 모아 IN 쿼리 1회로 {id:name} 맵을 만들어 매핑. + # (공용 카드는 user_id=NULL → 맵에 없어 creator_name=None). 행마다 조회하지 않으므로 부하 없음. + author_ids = list({c.user_id for c in page if c.user_id is not None}) + if author_ids: + nm_err, name_map = await DB_SESSION_MNG.execute_lambda( + nego_cards.DBType(), + DBWRType.DB_READ.value, + lambda s: self.card_crud.user_name_map(s, author_ids), + ) + if nm_err == ErrorType.SUCCESS: + for c in page: + c.creator_name = name_map.get(c.user_id) + res.cards = page res.total_nego = total_n res.total_wild = total_w # 선택된 탭 기준 페이지네이션 총건수(전체=합산). @@ -134,6 +151,14 @@ class CardService: res.result.SetResult(err) return res res.card = self._wild_to_data(row) if is_wild else self._nego_to_data(row) + # 등록자명 — 공용(user_id NULL) 카드는 작성자 없음(None 유지). + if row.user_id is not None: + nm_err, name_map = await DB_SESSION_MNG.execute_lambda( + nego_cards.DBType(), DBWRType.DB_READ.value, + lambda s: self.card_crud.user_name_map(s, [row.user_id]), + ) + if nm_err == ErrorType.SUCCESS: + res.card.creator_name = name_map.get(row.user_id) return res # ---- 등록 ---------------------------------------------------------------- @@ -141,9 +166,11 @@ class CardService: res = Res_Card() user_uuid = uuid.UUID(user_id) is_wildcard = req.is_wildcard + # 전체(공용) 카드는 소유자 없이 저장(user_id NULL) → 모든 유저 목록에 노출. + owner_id = None if req.is_shared else user_uuid common = dict( - user_id=user_uuid, + user_id=owner_id, name=req.name, number=req.number, script=req.script, diff --git a/negodata/backend/services/item_service.py b/negodata/backend/services/item_service.py index 5dfd86d..684e2fe 100644 --- a/negodata/backend/services/item_service.py +++ b/negodata/backend/services/item_service.py @@ -2,6 +2,7 @@ import uuid from fastapi import Depends, UploadFile +from common.authz import is_owner_or_admin from common.database.db_session_manager import DB_SESSION_MNG from common.database.model.models import items from common.enums import DBWRType, ErrorType @@ -56,6 +57,16 @@ class ItemService: res.result.SetResult(err_type) return res res.items = [ItemData.model_validate(r) for r in rows] + # 등록자명 배치 조인 — 페이지 상품의 user_id를 모아 IN 쿼리 1회로 {id:name} 맵을 만들어 매핑(행별 조회 아님). + author_ids = list({r.user_id for r in rows if r.user_id is not None}) + if author_ids: + nm_err, name_map = await DB_SESSION_MNG.execute_lambda( + items.DBType(), DBWRType.DB_READ.value, + lambda s: self.item_crud.user_name_map(s, author_ids), + ) + if nm_err == ErrorType.SUCCESS: + for d in res.items: + d.creator_name = name_map.get(d.user_id) res.total = total return res @@ -81,6 +92,13 @@ class ItemService: res.result.SetResult(err_type) return res res.item = ItemData.model_validate(item) + if item.user_id is not None: + nm_err, name_map = await DB_SESSION_MNG.execute_lambda( + items.DBType(), DBWRType.DB_READ.value, + lambda s: self.item_crud.user_name_map(s, [item.user_id]), + ) + if nm_err == ErrorType.SUCCESS: + res.item.creator_name = name_map.get(item.user_id) return res async def check_codes(self, company_id: str, codes: list) -> Res_CheckCodes: @@ -128,17 +146,22 @@ class ItemService: # 서버 기본값(created_at/updated_at)은 insert 후 Python 객체에 실리지 않으므로 재조회한다. return await self.get_item(company_id, str(item.item_id)) - async def update_item(self, company_id: str, item_id: str, req: Req_UpdateItem) -> Res_Item: + async def update_item(self, company_id: str, item_id: str, req: Req_UpdateItem, user_id=None, role=None) -> Res_Item: res = Res_Item() company_uuid = uuid.UUID(company_id) item_uuid = uuid.UUID(item_id) data = req.model_dump(exclude_unset=True) - # 소유권 확인 - err_type, _ = await self._fetch_owned(company_uuid, item_uuid) - if err_type != ErrorType.SUCCESS: + # 회사 스코프 확인 + err_type, item = await self._fetch_owned(company_uuid, item_uuid) + if err_type != ErrorType.SUCCESS or item is None: res.result.SetResult(err_type) return res + # 소유자 게이팅 — 본인이 등록한 상품 또는 최고관리자만 수정(user_id 미지정=내부 호출은 스킵). + if user_id is not None and not is_owner_or_admin(item.user_id, user_id, role): + res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN) + res.msg = "본인이 등록한 상품만 수정할 수 있습니다." + return res err_type = await DB_SESSION_MNG.execute_lambda_run( [items.DBType()], @@ -151,15 +174,20 @@ class ItemService: # 갱신 후 재조회 return await self.get_item(company_id, item_id) - async def delete_item(self, company_id: str, item_id: str) -> Res_DeleteItem: + async def delete_item(self, company_id: str, item_id: str, user_id=None, role=None) -> Res_DeleteItem: res = Res_DeleteItem() company_uuid = uuid.UUID(company_id) item_uuid = uuid.UUID(item_id) - err_type, _ = await self._fetch_owned(company_uuid, item_uuid) - if err_type != ErrorType.SUCCESS: + err_type, item = await self._fetch_owned(company_uuid, item_uuid) + if err_type != ErrorType.SUCCESS or item is None: res.result.SetResult(err_type) return res + # 소유자 게이팅 — 본인이 등록한 상품 또는 최고관리자만 삭제(user_id 미지정=내부 호출은 스킵). + if user_id is not None and not is_owner_or_admin(item.user_id, user_id, role): + res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN) + res.msg = "본인이 등록한 상품만 삭제할 수 있습니다." + return res err_type = await DB_SESSION_MNG.execute_lambda_run( [items.DBType()], diff --git a/negodata/backend/services/quotation_service.py b/negodata/backend/services/quotation_service.py index d876f7f..0bdc31f 100644 --- a/negodata/backend/services/quotation_service.py +++ b/negodata/backend/services/quotation_service.py @@ -12,6 +12,7 @@ from common.anchoring import ( fetch_current_values, get_base_anchoring_value, ) +from common.authz import is_owner_or_admin from common.database.db_session_manager import DB_SESSION_MNG from common.database.model.models import quotations, sessions, chats, versions, version_nego_cards, version_wild_cards from common.enums import CloseOutcome, CloseReason, DBWRType, ErrorType, NotificationType, PriceGateAction, QuotationStatus, QuotationType, SessionStatus @@ -678,7 +679,7 @@ class QuotationService: # 4) 전원 미응찰 → 개찰(미응찰). return await self._open(qt_uuid, original, CloseReason.OPEN_NOSHOW.value, "no_show") - async def regenerate_quotation(self, qt_id: str, company_id, supplier_ids: list) -> Res_CreateQuotation: + async def regenerate_quotation(self, qt_id: str, company_id, supplier_ids: list, user_id=None, role=None) -> Res_CreateQuotation: """[프론트] 마감된 견적을 골라 수동으로 다음 라운드를 생성한다. 크론/수동마감의 자동 재생성과 달리 사유·체인 한도 판정 없이, 프론트가 고른 공급사로 바로 만든다. 상품·기간·견적번호·카드버전은 원 견적에서 이어받는다(regenerate_next_round).""" @@ -689,6 +690,11 @@ class QuotationService: if err_type != ErrorType.SUCCESS or original is None: res.result.SetResult(err_type) return res + # 소유자 게이팅 — 본인 견적 또는 최고관리자만 재생성(user_id 미지정=내부 호출은 스킵). + if user_id is not None and not is_owner_or_admin(original.user_id, user_id, role): + res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN) + res.msg = "본인이 생성한 견적만 재생성할 수 있습니다." + return res # 마감된 견적만 재생성(진행 중인 라운드를 또 찍어 같은 번호가 동시에 살아있는 걸 막는다). if original.status != QuotationStatus.CLOSED.value: res.result.SetResult(ErrorType.INVALID_REQUEST_DATA) @@ -711,29 +717,106 @@ class QuotationService: return await self.regenerate_next_round(qt_uuid, supplier_ids) - async def stop_quotation(self, qt_id: str, company_id=None) -> Res_Quotation: + async def stop_quotation(self, qt_id: str, company_id=None, user_id=None, role=None) -> Res_Quotation: """[프론트] 수동 견적마감. 크론과 똑같은 마감 판정(close_and_decide)을 탄다 (단독낙찰 확정 / 동가·미참여면 다음 라운드 재생성 / 거부·한도면 그냥 마감).""" res = Res_Quotation() qt_uuid = uuid.UUID(qt_id) # 존재 확인(+회사 가드) - err_type, _ = await self._fetch(qt_uuid, company_id) - if err_type != ErrorType.SUCCESS: + err_type, original = await self._fetch(qt_uuid, company_id) + if err_type != ErrorType.SUCCESS or original is None: res.result.SetResult(err_type) return res + # 소유자 게이팅 — 본인 견적 또는 최고관리자만 마감(user_id 미지정=내부 호출은 스킵). + if user_id is not None and not is_owner_or_admin(original.user_id, user_id, role): + res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN) + res.msg = "본인이 생성한 견적만 마감할 수 있습니다." + return res await self.close_and_decide(qt_uuid) return await self.get_quotation(qt_id, company_id) - async def delete_quotation(self, qt_id: str, company_id=None) -> Res_DeleteQuotation: + async def award_quotation(self, qt_id: str, company_id, user_id, role, winner_supplier_id) -> Res_Quotation: + """[프론트] 개찰(낙찰자 미정 마감) 견적을 담당자가 직접 낙찰 처리한다. + 투찰한 협상완료(DONE) 세션 중 고른 협력사를 낙찰자로 박고 close_reason 을 AWARDED 로 바꾼다(직접 낙찰). + 자동 낙찰(close_and_decide)과 결과 컬럼은 같되, 알림에 manual 플래그로 '직접 낙찰'임을 남긴다. + 권한: 본인이 생성한 견적만. 단 최고관리자(OWNER)는 회사 내 남의 견적도 낙찰할 수 있다.""" + res = Res_Quotation() + qt_uuid = uuid.UUID(qt_id) + sp_uuid = winner_supplier_id if isinstance(winner_supplier_id, uuid.UUID) else uuid.UUID(str(winner_supplier_id)) + + # 존재 확인(+회사 가드) — 남의 회사 견적은 NOT_FOUND. + err_type, original = await self._fetch(qt_uuid, company_id) + if err_type != ErrorType.SUCCESS or original is None: + res.result.SetResult(err_type if err_type != ErrorType.SUCCESS else ErrorType.QUOTATION_NOT_FOUND) + return res + + # 자기 견적만 직접 낙찰 — 최고관리자(OWNER)만 회사 내 남의 견적도 허용. 되돌릴 수 없는 낙찰이라 백엔드에서 강제한다. + if not is_owner_or_admin(original.user_id, user_id, role): + res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN) + res.msg = "본인이 생성한 견적만 낙찰할 수 있습니다." + return res + + # 개찰(마감·낙찰자 미정, close_reason ∈ OPEN_*)만 직접 낙찰 대상. 진행중/이미 낙찰은 거부. + if original.status != QuotationStatus.CLOSED.value or original.close_reason not in ( + CloseReason.OPEN_PRICE.value, CloseReason.OPEN_EQUAL.value, + CloseReason.OPEN_NOSHOW.value, CloseReason.OPEN_REJECT.value, + ): + res.result.SetResult(ErrorType.INVALID_REQUEST_DATA) + res.msg = "개찰(낙찰자 미정) 상태의 견적만 직접 낙찰할 수 있습니다." + return res + + # 낙찰 후보 = 투찰한 협상완료(DONE) 세션. close_and_decide 와 같은 조회(list_sessions_status, + # 공급사 삭제돼도 포함되는 outerjoin)를 써서 자동낙찰과 후보 집합을 일치시킨다. + err_type, rows = await DB_SESSION_MNG.execute_lambda( + sessions.DBType(), + DBWRType.DB_READ.value, + lambda s: self.quotation_crud.list_sessions_status(s, qt_uuid), + ) + rows = rows if err_type == ErrorType.SUCCESS else [] + winner = next( + (r for r in rows + if r.status == SessionStatus.DONE.value and r.bid_price is not None and r.supplier_id == sp_uuid), + None, + ) + if winner is None: + res.result.SetResult(ErrorType.INVALID_REQUEST_DATA) + res.msg = "선택한 협력사는 이 견적의 낙찰 후보(투찰한 협상완료 협력사)가 아닙니다." + return res + + # [동시 직접낙찰 가드] 개찰→낙찰 원자 선점. 실제로 전이한 호출자만 통과(재클릭·경합 방어). + claim_err, claimed = await DB_SESSION_MNG.execute_lambda_claim( + quotations.DBType(), + lambda s: self.quotation_crud.claim_for_award(s, qt_uuid, sp_uuid, (winner.name or "")[:20]), + ) + if claim_err != ErrorType.SUCCESS or claimed == 0: + res.result.SetResult(ErrorType.INVALID_REQUEST_DATA) + res.msg = "이미 낙찰 처리된 견적입니다." + return res + + # 작성자 알림 — 자동낙찰과 같은 SUCCESS 코드, manual 플래그로 '직접 낙찰' 구분. + await create_notification( + original.user_id, NotificationType.SUCCESS, + {"qt_name": original.name, "qt_number": original.number, + "winner_name": winner.name, "winner_price": winner.bid_price, "manual": True}, + ref_qt_id=qt_uuid, + ) + return await self.get_quotation(qt_id, company_id) + + async def delete_quotation(self, qt_id: str, company_id=None, user_id=None, role=None) -> Res_DeleteQuotation: res = Res_DeleteQuotation() qt_uuid = uuid.UUID(qt_id) - err_type, _ = await self._fetch(qt_uuid, company_id) - if err_type != ErrorType.SUCCESS: + err_type, original = await self._fetch(qt_uuid, company_id) + if err_type != ErrorType.SUCCESS or original is None: res.result.SetResult(err_type) return res + # 소유자 게이팅 — 본인 견적 또는 최고관리자만 삭제(user_id 미지정=내부 호출은 스킵). + if user_id is not None and not is_owner_or_admin(original.user_id, user_id, role): + res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN) + res.msg = "본인이 생성한 견적만 삭제할 수 있습니다." + return res err_type = await DB_SESSION_MNG.execute_lambda_run( [quotations.DBType()], @@ -815,15 +898,20 @@ class QuotationService: return res # ----- 협상 초청 메일 (수동 발송) - async def notify_sessions(self, qt_id: str, company_id=None) -> Res_NotifySessions: + async def notify_sessions(self, qt_id: str, company_id=None, user_id=None, role=None) -> Res_NotifySessions: """[수동 발송] 견적의 '미발송' 세션(공급사 담당자)에게 협상 초청 메일을 일괄 발송한다. 대상 = email_sent_at IS NULL + 담당자 이메일 보유.""" res = Res_NotifySessions() qt_uuid = uuid.UUID(qt_id) err_type, quotation = await self._fetch(qt_uuid, company_id) - if err_type != ErrorType.SUCCESS: + if err_type != ErrorType.SUCCESS or quotation is None: res.result.SetResult(err_type) return res + # 소유자 게이팅 — 본인 견적 또는 최고관리자만 초청메일 발송(user_id 미지정=내부 호출은 스킵). + if user_id is not None and not is_owner_or_admin(quotation.user_id, user_id, role): + res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN) + res.msg = "본인이 생성한 견적만 초청 메일을 발송할 수 있습니다." + return res err_type, rows = await DB_SESSION_MNG.execute_lambda( sessions.DBType(), @@ -851,7 +939,7 @@ class QuotationService: await self._mark_emailed(sent_ids) return res - async def notify_session(self, session_id: str, company_id=None) -> Res_NotifySessions: + async def notify_session(self, session_id: str, company_id=None, user_id=None, role=None) -> Res_NotifySessions: """[수동 재발송] 단일 세션(공급사)에 초청 메일 발송(이미 보냈어도 강제 재발송).""" res = Res_NotifySessions() sess_uuid = uuid.UUID(session_id) @@ -866,9 +954,14 @@ class QuotationService: sess, sp_name, email = got[0], got[1], got[2] res.total = 1 err_type, quotation = await self._fetch(sess.quotation_id, company_id) - if err_type != ErrorType.SUCCESS: + if err_type != ErrorType.SUCCESS or quotation is None: res.result.SetResult(err_type) return res + # 소유자 게이팅 — 본인 견적 또는 최고관리자만 재발송(user_id 미지정=내부 호출은 스킵). + if user_id is not None and not is_owner_or_admin(quotation.user_id, user_id, role): + res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN) + res.msg = "본인이 생성한 견적만 초청 메일을 발송할 수 있습니다." + return res if not email: res.skipped = 1 return res diff --git a/negodata/backend/services/supplier_service.py b/negodata/backend/services/supplier_service.py index 6b52403..d7705f0 100644 --- a/negodata/backend/services/supplier_service.py +++ b/negodata/backend/services/supplier_service.py @@ -51,6 +51,16 @@ class SupplierService: res.result.SetResult(err_type) return res res.suppliers = [SupplierData.model_validate(r) for r in rows] + # 등록자명 배치 조인 — 페이지 협력사의 user_id를 모아 IN 쿼리 1회로 {id:name} 맵을 만들어 매핑(행별 조회 아님). + author_ids = list({r.user_id for r in rows if r.user_id is not None}) + if author_ids: + nm_err, name_map = await DB_SESSION_MNG.execute_lambda( + suppliers.DBType(), DBWRType.DB_READ.value, + lambda s: self.supplier_crud.user_name_map(s, author_ids), + ) + if nm_err == ErrorType.SUCCESS: + for d in res.suppliers: + d.creator_name = name_map.get(d.user_id) res.total = total return res @@ -61,6 +71,13 @@ class SupplierService: res.result.SetResult(err_type) return res res.supplier = SupplierData.model_validate(supplier) + if supplier.user_id is not None: + nm_err, name_map = await DB_SESSION_MNG.execute_lambda( + suppliers.DBType(), DBWRType.DB_READ.value, + lambda s: self.supplier_crud.user_name_map(s, [supplier.user_id]), + ) + if nm_err == ErrorType.SUCCESS: + res.supplier.creator_name = name_map.get(supplier.user_id) return res async def check_codes(self, company_id: str, codes: list) -> Res_CheckCodes: diff --git a/negodata/backend/tests/test_card.py b/negodata/backend/tests/test_card.py new file mode 100644 index 0000000..2445001 --- /dev/null +++ b/negodata/backend/tests/test_card.py @@ -0,0 +1,65 @@ +"""card 도메인 스코프 e2e — 개인 카드는 소유자만, 전체(공용) 카드는 누구나. +스코프 규칙: user_id 있으면 개인(본인만 조회·관리) / NULL 이면 전체(모든 유저 조회·수정·삭제). 로그인은 auth_headers.""" + + +async def _create_card(client, headers, *, number, name="카드", is_shared=False, is_wildcard=False): + r = await client.post( + "/v1/card/create", + json={ + "is_wildcard": is_wildcard, + "is_shared": is_shared, + "name": name, + "number": number, + "script": "안녕하세요", + }, + headers=headers, + ) + body = r.json() + assert body["result"]["success"] is True, body + return body["card"]["nego_card_id"] + + +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).""" + ha = await auth_headers("cardA") + hb = await auth_headers("cardB") + cid = await _create_card(client, ha, number="P-1", is_shared=False) + + 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 + + +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.""" + ha = await auth_headers("cardSA") + hb = await auth_headers("cardSB") + cid = await _create_card(client, ha, number="S-1", is_shared=True) + + 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 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) + + upd = await client.patch(f"/v1/card/update/{cid}", json={"name": "B가 수정"}, headers=hb) + assert upd.json()["result"]["success"] is True + + dele = await client.delete(f"/v1/card/delete/{cid}", headers=hb) + assert dele.json()["result"]["success"] is True + assert "S-EDIT" not in await _list_numbers(client, hb) diff --git a/negodata/backend/tests/test_ownership_gating.py b/negodata/backend/tests/test_ownership_gating.py new file mode 100644 index 0000000..6ebb2cd --- /dev/null +++ b/negodata/backend/tests/test_ownership_gating.py @@ -0,0 +1,122 @@ +"""소유자 게이팅 — 변경 액션은 '본인 소유' 또는 '최고관리자(OWNER)'만. 프론트 버튼 차단과 같은 규칙을 백엔드가 강제한다. + +- item(상품): 같은 회사의 다른 일반유저는 남의 상품을 수정·삭제 못 한다(OWNER는 가능). +- quotation(견적): 남의 견적 삭제는 비소유 일반유저 차단, OWNER 허용, user_id 미지정(내부 호출)은 스킵. +공용 판정은 common.authz.is_owner_or_admin — 여기 통과하면 다른 변경 액션(마감·재생성·초청메일)도 같은 규칙을 탄다. +""" +import uuid +from datetime import datetime + +from sqlalchemy import text + +from common.enums import ErrorType, QuotationStatus, QuotationType, UserRole +from crud.quotation_crud import QuotationCRUD +from services.quotation_service import QuotationService + +PAST = datetime(2020, 1, 1) + + +# ===== item(상품) — HTTP e2e: 같은 회사, 다른 유저 ===== +async def test_item_update_delete_blocked_for_non_owner(client, auth_headers): + """검증: A가 등록한 상품을 같은 회사의 다른 일반유저 B가 수정·삭제 시도. + 기대결과: 둘 다 거부(ACCOUNT_FORBIDDEN), 상품은 원값 그대로 남는다.""" + ha = await auth_headers("item_owner") # 소유자 + hb = await auth_headers("item_other") # 같은 회사, USER + item_id = (await client.post( + "/v1/item/create", json={"name": "상품A", "price": 1000, "code": "OWN1"}, headers=ha + )).json()["item"]["item_id"] + + r_upd = await client.patch(f"/v1/item/update/{item_id}", json={"price": 9999}, headers=hb) + assert r_upd.json()["result"]["success"] is False + assert r_upd.json()["result"]["code"] == ErrorType.ACCOUNT_FORBIDDEN.value + + r_del = await client.delete(f"/v1/item/delete/{item_id}", headers=hb) + assert r_del.json()["result"]["success"] is False + assert r_del.json()["result"]["code"] == ErrorType.ACCOUNT_FORBIDDEN.value + + # 수정·삭제 모두 무산 — 원값으로 조회된다 + assert (await client.get(f"/v1/item/{item_id}", headers=ha)).json()["item"]["price"] == 1000 + + +async def test_item_delete_allowed_for_owner_role(client, auth_headers): + """검증: A가 등록한 상품을 같은 회사 최고관리자(OWNER)가 삭제. + 기대결과: 성공 — 소유자가 아니어도 OWNER는 허용.""" + ha = await auth_headers("item_owner2") + hadmin = await auth_headers("item_admin", role=UserRole.OWNER.value) + item_id = (await client.post( + "/v1/item/create", json={"name": "상품B", "price": 500, "code": "OWN2"}, headers=ha + )).json()["item"]["item_id"] + + r = await client.delete(f"/v1/item/delete/{item_id}", headers=hadmin) + assert r.json()["result"]["success"] is True + + +# ===== quotation(견적) — 서비스 직접: 삭제 게이팅 ===== +async def test_quotation_delete_blocked_for_non_owner(db_engine): + """검증: 남의 견적을 비소유 일반유저(USER)가 삭제 시도. + 기대결과: 거부(ACCOUNT_FORBIDDEN) + soft-delete 안 됨(deleted=false).""" + owner, other = uuid.uuid4(), uuid.uuid4() + qt = await _seed_quotation(db_engine, user_id=owner, number="DEL-NONOWNER") + + res = await _service().delete_quotation(str(qt), None, other, UserRole.USER.value) + + assert res.result.success is False + assert res.result.code == ErrorType.ACCOUNT_FORBIDDEN.value + assert await _deleted(db_engine, qt) is False + + +async def test_quotation_delete_allowed_for_owner_role(db_engine): + """검증: 남의 견적을 최고관리자(OWNER)가 삭제. + 기대결과: 성공 + deleted=true.""" + creator, admin = uuid.uuid4(), uuid.uuid4() + qt = await _seed_quotation(db_engine, user_id=creator, number="DEL-OWNER") + + res = await _service().delete_quotation(str(qt), None, admin, UserRole.OWNER.value) + + assert res.result.success is True + assert await _deleted(db_engine, qt) is True + + +async def test_quotation_delete_skips_gate_for_internal_call(db_engine): + """검증: user_id 미지정(내부/스케줄러 호출)로 삭제. + 기대결과: 소유권 검사 스킵 → 성공(회사 스코프만 적용).""" + creator = uuid.uuid4() + qt = await _seed_quotation(db_engine, user_id=creator, number="DEL-INTERNAL") + + res = await _service().delete_quotation(str(qt), None) + + assert res.result.success is True + assert await _deleted(db_engine, qt) is True + + +# ===== 헬퍼 ===== +async def _seed_quotation(engine, *, user_id, number): + """견적 1건 시드(삭제 게이팅 확인용 — 상태는 무관하므로 CLOSED로 고정).""" + qt_id = uuid.uuid4() + async with engine.begin() as conn: + await conn.execute( + text( + "INSERT INTO quotations " + "(qt_id, user_id, qt_setting_id, version_id, name, number, type, status, " + " round, iteration, start_time, end_time, deleted) VALUES " + "(:qt_id, :user_id, :qt_setting_id, :version_id, '견적', :number, :type, :status, " + " 1, 0, :t, :t, false)" + ), + { + "qt_id": qt_id, "user_id": user_id, "qt_setting_id": uuid.uuid4(), + "version_id": uuid.uuid4(), "number": number, "type": QuotationType.REQUOTE.value, + "status": QuotationStatus.CLOSED.value, "t": PAST, + }, + ) + return qt_id + + +async def _deleted(engine, qt_id) -> bool: + async with engine.begin() as conn: + return (await conn.execute( + text("SELECT deleted FROM quotations WHERE qt_id = :q"), {"q": qt_id} + )).scalar_one() + + +def _service(): + return QuotationService(QuotationCRUD()) diff --git a/negodata/backend/tests/test_quotation_award.py b/negodata/backend/tests/test_quotation_award.py new file mode 100644 index 0000000..ac61b8b --- /dev/null +++ b/negodata/backend/tests/test_quotation_award.py @@ -0,0 +1,206 @@ +"""개찰 견적 직접 낙찰(award_quotation) 테스트 — 담당자가 개찰(낙찰자 미정 마감) 견적의 낙찰자를 직접 지정. + +직접 낙찰은 자동 낙찰(close_and_decide)과 결과 컬럼은 같되(close_reason=AWARDED, preferred_sp_*), +알림에 manual 플래그로 '직접' 낙찰임을 남긴다. 다음을 본다: + · 개찰 + 투찰(DONE) 협력사 지정 → 낙찰 확정 + 알림 SUCCESS(manual=True) + · 개찰 아님(이미 낙찰) → 거부(INVALID_REQUEST_DATA), 알림 없음 + · 후보 아닌 협력사 지정 → 거부, close_reason 유지 + · 낙찰 후 재지정(재클릭) → 거부(동시성 가드), 알림 1건 유지 + +세션의 협상 결과(협상완료/입찰가)와 개찰 상태(close_reason)는 협상/마감에서만 생기는 값이라 SQL 로 직접 넣는다. +""" +import uuid +from datetime import datetime + +import pytest_asyncio +from sqlalchemy import text + +from common.enums import CloseReason, ErrorType, QuotationStatus, QuotationType, SessionStatus, UserRole +from crud.quotation_crud import QuotationCRUD +from services.quotation_service import QuotationService + +PAST = datetime(2020, 1, 1) + + +@pytest_asyncio.fixture +async def clean(db_engine): + """conftest 는 notifications 를 비우지 않는다 → 알림 단언이 다른 테스트에 안 흔들리게 여기서 함께 비운다.""" + async with db_engine.begin() as conn: + await conn.execute(text("TRUNCATE TABLE sessions, quotations, notifications RESTART IDENTITY CASCADE")) + return db_engine + + +async def test_award_opened_sets_winner_and_notifies(clean): + """검증: 개찰(동가) 견적 + 투찰 협력사 2건(A=100, B=120) 중 A 를 직접 낙찰. + 기대결과: close_reason→낙찰, preferred_sp=A, 동가플래그 해제 + 알림 SUCCESS(manual=True, winner_price=100).""" + engine = clean + user_id, supplier_a = uuid.uuid4(), uuid.uuid4() + qt = await _seed_opened(engine, user_id=user_id, number="A-WIN", close_reason=CloseReason.OPEN_EQUAL.value) + await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a) + await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=120) + + res = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, supplier_a) + + assert res.result.success is True + row = await _quotation(engine, qt) + assert row.close_reason == CloseReason.AWARDED.value + assert row.preferred_sp_yn is True + assert str(row.preferred_sp_id) == str(supplier_a) + assert row.equal_bid_yn is False + + notis = await _notifications(engine, user_id) + assert len(notis) == 1 + type_, data, ref = notis[0] + assert type_ == 1 # NotificationType.SUCCESS + assert data["manual"] is True + assert data["winner_price"] == 100 + assert str(ref) == str(qt) + + +async def test_award_rejects_when_not_opened(clean): + """검증: 이미 낙찰된 견적(close_reason=AWARDED)에 직접 낙찰을 다시 시도. + 기대결과: 거부(INVALID_REQUEST_DATA) + 새 알림 없음.""" + engine = clean + user_id, supplier_a = uuid.uuid4(), uuid.uuid4() + qt = await _seed_opened(engine, user_id=user_id, number="A-DONE", close_reason=CloseReason.AWARDED.value) + await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a) + + res = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, supplier_a) + + assert res.result.success is False + assert res.result.code == ErrorType.INVALID_REQUEST_DATA.value + assert len(await _notifications(engine, user_id)) == 0 + + +async def test_award_rejects_unknown_supplier(clean): + """검증: 개찰 견적에, 투찰 후보가 아닌 협력사 id 를 지정. + 기대결과: 거부 + close_reason 은 개찰(OPEN_PRICE) 그대로 유지, 알림 없음.""" + engine = clean + user_id, bidder, stranger = uuid.uuid4(), uuid.uuid4(), uuid.uuid4() + qt = await _seed_opened(engine, user_id=user_id, number="A-STRANGER", close_reason=CloseReason.OPEN_PRICE.value) + await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=bidder) + + res = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, stranger) + + assert res.result.success is False + row = await _quotation(engine, qt) + assert row.close_reason == CloseReason.OPEN_PRICE.value + assert row.preferred_sp_id is None + assert len(await _notifications(engine, user_id)) == 0 + + +async def test_award_is_idempotent(clean): + """검증: 직접 낙찰 성공 후 같은 견적에 재지정(재클릭/경합). + 기대결과: 2번째는 거부(이미 낙찰) + 알림은 1건만 유지(동시성 가드가 한 번만 통과).""" + engine = clean + user_id, supplier_a = uuid.uuid4(), uuid.uuid4() + qt = await _seed_opened(engine, user_id=user_id, number="A-IDEMP", close_reason=CloseReason.OPEN_REJECT.value) + await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a) + + first = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, supplier_a) + second = await _service().award_quotation(str(qt), None, user_id, UserRole.USER.value, supplier_a) + + assert first.result.success is True + assert second.result.success is False + assert len(await _notifications(engine, user_id)) == 1 + + +async def test_award_rejects_non_owner(clean): + """검증: 남의 개찰 견적을 일반 유저(비소유·USER)가 직접 낙찰 시도. + 기대결과: 거부(ACCOUNT_FORBIDDEN) + close_reason 개찰 유지 + 알림 없음.""" + engine = clean + owner, other, supplier_a = uuid.uuid4(), uuid.uuid4(), uuid.uuid4() + qt = await _seed_opened(engine, user_id=owner, number="A-NONOWNER", close_reason=CloseReason.OPEN_PRICE.value) + await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a) + + res = await _service().award_quotation(str(qt), None, other, UserRole.USER.value, supplier_a) + + assert res.result.success is False + assert res.result.code == ErrorType.ACCOUNT_FORBIDDEN.value + row = await _quotation(engine, qt) + assert row.close_reason == CloseReason.OPEN_PRICE.value + assert row.preferred_sp_id is None + assert len(await _notifications(engine, owner)) == 0 + + +async def test_award_allows_owner_role(clean): + """검증: 남의 개찰 견적을 최고관리자(OWNER)가 직접 낙찰. + 기대결과: 낙찰 성공 + 알림은 견적 작성자(owner) 인박스에 남는다(호출자가 아니라).""" + engine = clean + creator, admin, supplier_a = uuid.uuid4(), uuid.uuid4(), uuid.uuid4() + qt = await _seed_opened(engine, user_id=creator, number="A-OWNER", close_reason=CloseReason.OPEN_EQUAL.value) + await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=supplier_a) + + res = await _service().award_quotation(str(qt), None, admin, UserRole.OWNER.value, supplier_a) + + assert res.result.success is True + row = await _quotation(engine, qt) + assert row.close_reason == CloseReason.AWARDED.value + assert str(row.preferred_sp_id) == str(supplier_a) + assert len(await _notifications(engine, creator)) == 1 + assert len(await _notifications(engine, admin)) == 0 + + +# ===== 헬퍼 ===== +async def _seed_opened(engine, *, user_id, number, close_reason, round_=1): + """개찰/낙찰 상태(status=CLOSED + close_reason)로 견적 1건 시드. 낙찰자 컬럼은 비운 채 시작.""" + qt_id = uuid.uuid4() + async with engine.begin() as conn: + await conn.execute( + text( + "INSERT INTO quotations " + "(qt_id, user_id, qt_setting_id, version_id, name, number, type, status, close_reason, " + " round, iteration, start_time, end_time, deleted) VALUES " + "(:qt_id, :user_id, :qt_setting_id, :version_id, '견적A', :number, :type, :status, :close_reason, " + " :round, 0, :start_time, :end_time, false)" + ), + { + "qt_id": qt_id, "user_id": user_id, "qt_setting_id": uuid.uuid4(), + "version_id": uuid.uuid4(), "number": number, "type": QuotationType.REQUOTE.value, + "status": QuotationStatus.CLOSED.value, "close_reason": close_reason, + "round": round_, "start_time": PAST, "end_time": PAST, + }, + ) + return qt_id + + +async def _add_session(engine, qt_id, *, status, bid_price=None, supplier_id=None): + """세션 1건 시드(공급사 협상 1건). status/bid_price 로 협상완료·입찰가를 만든다.""" + async with engine.begin() as conn: + await conn.execute( + text( + "INSERT INTO sessions " + "(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, " + " target_price, status, bid_price, end_time) VALUES " + "(:session_id, :quotation_id, :item_id, :supplier_id, 'Q', 1, :qt_type, " + " 0, :status, :bid_price, :end_time)" + ), + { + "session_id": uuid.uuid4(), "quotation_id": qt_id, "item_id": uuid.uuid4(), + "supplier_id": supplier_id or uuid.uuid4(), "qt_type": QuotationType.REQUOTE.value, + "status": status, "bid_price": bid_price, "end_time": PAST, + }, + ) + + +async def _quotation(engine, qt_id): + """견적 1행(마감 결과 컬럼 확인용).""" + async with engine.begin() as conn: + return (await conn.execute( + text("SELECT close_reason, preferred_sp_yn, preferred_sp_id, equal_bid_yn " + "FROM quotations WHERE qt_id = :qt_id"), + {"qt_id": qt_id}, + )).one() + + +async def _notifications(engine, user_id): + """user_id(작성자) 인박스 알림 (type, data, ref_qt_id) — 생성순.""" + async with engine.begin() as conn: + return (await conn.execute( + text("SELECT type, data, ref_qt_id FROM notifications WHERE user_id = :uid ORDER BY created_at"), + {"uid": user_id}, + )).all() + + +def _service(): + return QuotationService(QuotationCRUD()) diff --git a/negodata/front/src/api/generated/model/cardData.ts b/negodata/front/src/api/generated/model/cardData.ts index ea26d31..9f1b37c 100644 --- a/negodata/front/src/api/generated/model/cardData.ts +++ b/negodata/front/src/api/generated/model/cardData.ts @@ -5,6 +5,7 @@ * OpenAPI spec version: 0.1.0 */ import type { CardDataUserId } from './cardDataUserId'; +import type { CardDataCreatorName } from './cardDataCreatorName'; import type { CardDataName } from './cardDataName'; import type { CardDataNumber } from './cardDataNumber'; import type { CardDataScript } from './cardDataScript'; @@ -20,6 +21,8 @@ export interface CardData { nego_card_id: string; user_id?: CardDataUserId; is_wildcard?: boolean; + is_shared?: boolean; + creator_name?: CardDataCreatorName; name?: CardDataName; number?: CardDataNumber; script?: CardDataScript; diff --git a/negodata/front/src/api/generated/model/cardDataCreatorName.ts b/negodata/front/src/api/generated/model/cardDataCreatorName.ts new file mode 100644 index 0000000..5da6efb --- /dev/null +++ b/negodata/front/src/api/generated/model/cardDataCreatorName.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type CardDataCreatorName = string | null; diff --git a/negodata/front/src/api/generated/model/itemData.ts b/negodata/front/src/api/generated/model/itemData.ts index 1477144..c27d404 100644 --- a/negodata/front/src/api/generated/model/itemData.ts +++ b/negodata/front/src/api/generated/model/itemData.ts @@ -4,6 +4,7 @@ * Negodata Api Server * OpenAPI spec version: 0.1.0 */ +import type { ItemDataCreatorName } from './itemDataCreatorName'; import type { ItemDataCode } from './itemDataCode'; import type { ItemDataCategory } from './itemDataCategory'; import type { ItemDataImageUrl } from './itemDataImageUrl'; @@ -28,6 +29,7 @@ export interface ItemData { item_id: string; company_id: string; user_id: string; + creator_name?: ItemDataCreatorName; name: string; code?: ItemDataCode; category?: ItemDataCategory; diff --git a/negodata/front/src/api/generated/model/itemDataCreatorName.ts b/negodata/front/src/api/generated/model/itemDataCreatorName.ts new file mode 100644 index 0000000..a7c6eaa --- /dev/null +++ b/negodata/front/src/api/generated/model/itemDataCreatorName.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ItemDataCreatorName = string | null; diff --git a/negodata/front/src/api/generated/model/reqAwardQuotation.ts b/negodata/front/src/api/generated/model/reqAwardQuotation.ts new file mode 100644 index 0000000..0e85381 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqAwardQuotation.ts @@ -0,0 +1,10 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export interface ReqAwardQuotation { + winner_supplier_id: string; +} diff --git a/negodata/front/src/api/generated/model/reqCreateCard.ts b/negodata/front/src/api/generated/model/reqCreateCard.ts index 467e7da..46e2b63 100644 --- a/negodata/front/src/api/generated/model/reqCreateCard.ts +++ b/negodata/front/src/api/generated/model/reqCreateCard.ts @@ -13,6 +13,7 @@ import type { ReqCreateCardMemo } from './reqCreateCardMemo'; export interface ReqCreateCard { is_wildcard?: boolean; + is_shared?: boolean; name?: ReqCreateCardName; number?: ReqCreateCardNumber; script?: ReqCreateCardScript; diff --git a/negodata/front/src/api/generated/model/supplierData.ts b/negodata/front/src/api/generated/model/supplierData.ts index 6da4f05..a28220c 100644 --- a/negodata/front/src/api/generated/model/supplierData.ts +++ b/negodata/front/src/api/generated/model/supplierData.ts @@ -4,6 +4,7 @@ * Negodata Api Server * OpenAPI spec version: 0.1.0 */ +import type { SupplierDataCreatorName } from './supplierDataCreatorName'; import type { SupplierDataCode } from './supplierDataCode'; import type { SupplierDataManagerName } from './supplierDataManagerName'; import type { SupplierDataManagerEmail } from './supplierDataManagerEmail'; @@ -16,6 +17,7 @@ export interface SupplierData { supplier_id: string; company_id: string; user_id: string; + creator_name?: SupplierDataCreatorName; name: string; code?: SupplierDataCode; manager_name?: SupplierDataManagerName; diff --git a/negodata/front/src/api/generated/model/supplierDataCreatorName.ts b/negodata/front/src/api/generated/model/supplierDataCreatorName.ts new file mode 100644 index 0000000..031aa49 --- /dev/null +++ b/negodata/front/src/api/generated/model/supplierDataCreatorName.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type SupplierDataCreatorName = string | null; diff --git a/negodata/front/src/api/generated/quotation/quotation.ts b/negodata/front/src/api/generated/quotation/quotation.ts index a0a91a2..d055c72 100644 --- a/negodata/front/src/api/generated/quotation/quotation.ts +++ b/negodata/front/src/api/generated/quotation/quotation.ts @@ -26,6 +26,7 @@ import type { import type { HTTPValidationError, ListQuotationsParams, + ReqAwardQuotation, ReqCreateQuotation, ReqRegenerateQuotation, ResCreateQuotation, @@ -269,6 +270,71 @@ export const useStopQuotation = ,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/quotation/award/${qtId}`, method: 'POST', + headers: {'Content-Type': 'application/json', }, + data: reqAwardQuotation, signal + }, + options); + } + + + +export const getAwardQuotationMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{qtId: string;data: ReqAwardQuotation}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{qtId: string;data: ReqAwardQuotation}, TContext> => { + +const mutationKey = ['awardQuotation']; +const {mutation: mutationOptions, request: requestOptions} = options ? + options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? + options + : {...options, mutation: {...options.mutation, mutationKey}} + : {mutation: { mutationKey, }, request: undefined}; + + + + + const mutationFn: MutationFunction>, {qtId: string;data: ReqAwardQuotation}> = (props) => { + const {qtId,data} = props ?? {}; + + return awardQuotation(qtId,data,requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type AwardQuotationMutationResult = NonNullable>> + export type AwardQuotationMutationBody = ReqAwardQuotation + export type AwardQuotationMutationError = void | HTTPValidationError + + /** + * @summary 개찰 견적 직접 낙찰 + */ +export const useAwardQuotation = (options?: { mutation?:UseMutationOptions>, TError,{qtId: string;data: ReqAwardQuotation}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {qtId: string;data: ReqAwardQuotation}, + TContext + > => { + + const mutationOptions = getAwardQuotationMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } + /** * @summary 견적 재생성(다음 라운드) */ export const regenerateQuotation = ( diff --git a/negodata/front/src/components/ui/phone-input.tsx b/negodata/front/src/components/ui/phone-input.tsx new file mode 100644 index 0000000..2d77209 --- /dev/null +++ b/negodata/front/src/components/ui/phone-input.tsx @@ -0,0 +1,26 @@ +import { forwardRef } from 'react'; +import { Input } from './input'; +import { formatPhoneKR, normalizePhone } from '@/lib/phone'; + +// 연락처 전용 입력. 표시는 하이픈 자동(010/02/031/1588 등), 밖으로 넘기는 값은 숫자만. +// react-hook-form Controller 의 field(value/onChange/onBlur)를 그대로 물리면 된다. +type PhoneInputProps = Omit, 'value' | 'onChange' | 'type'> & { + value?: string; // 숫자만(정규화된 폼 값) + onChange?: (value: string) => void; // 숫자만으로 방출 +}; + +export const PhoneInput = forwardRef(function PhoneInput( + { value = '', onChange, ...props }, + ref, +) { + return ( + onChange?.(normalizePhone(e.target.value))} + {...props} + /> + ); +}); diff --git a/negodata/front/src/features/auth/components/ProfileSheet.tsx b/negodata/front/src/features/auth/components/ProfileSheet.tsx index 71f62cf..8f96f42 100644 --- a/negodata/front/src/features/auth/components/ProfileSheet.tsx +++ b/negodata/front/src/features/auth/components/ProfileSheet.tsx @@ -1,10 +1,12 @@ -import { useForm } from 'react-hook-form'; +import { useForm, Controller } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { showToast } from '@/lib/notify'; +import { normalizePhone } from '@/lib/phone'; import { Typography } from '@/components/ui/typography'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { PhoneInput } from '@/components/ui/phone-input'; import { Sheet } from '@/components/ui/sheet'; import { useAuth } from '../useAuth'; import { updateMe } from '../service'; @@ -26,6 +28,7 @@ export function ProfileSheet({ open, onClose }: { open: boolean; onClose: () => const { user } = useAuth(); const { register, + control, handleSubmit, setError, formState: { errors, isSubmitting }, @@ -34,7 +37,7 @@ export function ProfileSheet({ open, onClose }: { open: boolean; onClose: () => defaultValues: { name: user?.name ?? '', email: user?.email ?? '', - contactNumber: user?.contact ?? '', + contactNumber: normalizePhone(user?.contact ?? ''), password: '', passwordConfirm: '', }, @@ -98,7 +101,20 @@ export function ProfileSheet({ open, onClose }: { open: boolean; onClose: () => {/* 연락처 */}
연락처 - + ( + + )} + />
{/* 비밀번호 변경(옵션) */} diff --git a/negodata/front/src/features/cards/components/CardExcelUploadModal.tsx b/negodata/front/src/features/cards/components/CardExcelUploadModal.tsx new file mode 100644 index 0000000..1376622 --- /dev/null +++ b/negodata/front/src/features/cards/components/CardExcelUploadModal.tsx @@ -0,0 +1,372 @@ +import { useMemo, useRef, useState } from 'react'; +import { Upload, X, FileSpreadsheet, CheckCircle2, Trash2 } from 'lucide-react'; +import { useScrollLock } from '@/lib/useScrollLock'; +import { showToast } from '@/lib/notify'; +import { downloadExcel, parseCsv, type BulkFailure } from '@/lib/excel'; +import { Typography } from '@/components/ui/typography'; +import { Input } from '@/components/ui/input'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { CardUsageType } from '@/api/generated/model'; +import { deserialize } from '../editor'; +import type { CardInput } from '../hooks/useCards'; + +// 엑셀에서 읽어온 원본 행(입력값만). status/message는 검증에서 파생한다. +type RawRow = { + id: string; + rowNum: number; + kind: string; // 카드종류 원문(협상/와일드) + code: string; // 카드번호 + 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 }; + +// 카드종류 원문 → 와일드 여부. '와일드'/'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(); + if (/신규/.test(u)) return CardUsageType.NEW; + if (/재/.test(u)) return CardUsageType.REUSE; + return CardUsageType.COMMON; +} + +type CardExcelUploadModalProps = { + open: boolean; + onConfirm: (rows: CardInput[]) => Promise<{ failures: BulkFailure[] }>; + onClose: () => void; +}; + +// 행 검증 — 순수 함수. 우선순위 순으로 첫 위반 메시지를 매긴다. +// serverErrors: 서버가 거부한 code(카드번호)→사유. 프론트 검증 통과 행에만 마지막에 덧씌운다. +function validateRows(rows: RawRow[], serverErrors: Record): ValidatedRow[] { + return rows.map((row) => { + const fail = (message: string): ValidatedRow => ({ ...row, status: '오류', message }); + + if (!row.code.trim()) return fail('유효성 위반 - 카드번호를 입력해 주십시오.'); + if (!row.title.trim()) return fail('유효성 위반 - 카드이름을 입력해 주십시오.'); + if (!row.script.trim()) return fail('유효성 위반 - 스크립트를 입력해 주십시오.'); + + 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}` }; + }); +} + +// 검증된(정상) 행 → 서버 등록용 CardInput. 스크립트 평문은 Slate 노드로 복원(변수칩 재현). +function toCardInput(row: RawRow): CardInput { + const wild = isWild(row.kind); + return { + title: row.title, + code: row.code, + 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, + }; +} + +// 카드 엑셀 일괄 업로드 모달. 파일 파싱·원본 행 state는 이 컴포넌트가 소유하고, +// 검증은 렌더 시 validateRows로 파생한다. 실제 서버 등록은 onConfirm(검증된 행)으로 위임. +export function CardExcelUploadModal({ open, onConfirm, onClose }: CardExcelUploadModalProps) { + useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금 + const [excelFile, setExcelFile] = useState(null); + const [rows, setRows] = useState([]); + const [serverErrors, setServerErrors] = useState>({}); + const [isDragging, setIsDragging] = useState(false); + const fileInputRef = useRef(null); + + const validated = useMemo(() => validateRows(rows, serverErrors), [rows, serverErrors]); + const validRows = validated.filter((r) => r.status === '정상'); + const validCount = validRows.length; + const errorCount = validated.length - validCount; + + if (!open) return null; + + const close = () => { + setExcelFile(null); + setRows([]); + setServerErrors({}); + onClose(); + }; + + // 업로드된 CSV를 파싱해 원본 행으로 적재(검증은 자동 파생). 헤더는 양식과 동일해야 함. + const handleFile = async (file: File) => { + const parsed = parseCsv(await file.text()); + const loaded: RawRow[] = parsed.map((r, i) => ({ + id: `row-${i + 1}`, + rowNum: i + 2, + kind: r['카드종류'] ?? '', + code: r['카드번호'] ?? '', + title: r['카드이름'] ?? '', + script: r['스크립트'] ?? '', + usage: r['카드용도'] ?? '', + scope: r['공개범위'] ?? '', + condition: r['사용조건'] ?? '', + memo: r['메모'] ?? '', + })); + setExcelFile(file.name); + setRows(loaded); + setServerErrors({}); + }; + + const handleUpdateField = (id: string, field: 'code' | 'title' | 'script', value: string) => { + setRows((cur) => cur.map((row) => (row.id === id ? { ...row, [field]: value } : row))); + }; + + const handleRemoveRow = (id: string) => { + setRows((cur) => cur.filter((row) => row.id !== id)); + }; + + const handleConfirm = async () => { + if (validRows.length === 0) { + showToast('정합성이 무결한 카드 행이 존재하지 않습니다.', 'error'); + return; + } + try { + const { failures } = await onConfirm(validRows.map(toCardInput)); + const okCount = validRows.length - failures.length; + if (failures.length === 0) { + showToast(`총 ${okCount}개 카드가 서버에 일괄 등록되었습니다.`, 'success'); + close(); + return; + } + // 부분 성공: 등록 성공한 행만 제거하고, 서버가 거부한 행은 사유와 함께 남긴다. + const failMap: Record = {}; + failures.forEach((f) => { failMap[f.code] = f.message; }); + const okCodes = new Set(validRows.map((r) => r.code).filter((c) => failMap[c] === undefined)); + setServerErrors(failMap); + setRows((cur) => cur.filter((r) => !okCodes.has(r.code))); + showToast(`${okCount}건 등록 완료 · ${failures.length}건 서버 검증 실패`, 'error'); + } catch (err) { + showToast(err instanceof Error ? err.message : '엑셀 일괄 등록 실패', 'error'); + } + }; + + return ( +
+
+ + {/* Modal Title */} +
+
+ + 협상카드 엑셀 일괄 검증 및 등록 +
+ +
+ + {/* File select + drop */} +
+ {!excelFile ? ( +
{ e.preventDefault(); setIsDragging(true); }} + onDragLeave={() => setIsDragging(false)} + onDrop={(e) => { + e.preventDefault(); + setIsDragging(false); + const file = e.dataTransfer.files?.[0]; + if (file) handleFile(file); + }} + className={`border-2 border-dashed rounded-lg p-8 flex flex-col items-center justify-center transition-colors ${ + isDragging ? 'border-primary bg-primary/10' : 'border-border bg-muted/30' + }`} + > + + + 협상카드 리스트 엑셀 파일을 업로드해 주십시오 + + + 카드번호·카드이름·스크립트 필수 항목 검사를 수행합니다. 스크립트는 평문으로 등록됩니다. + + + { + const file = e.target.files?.[0]; + if (file) handleFile(file); + }} + /> + + +
+ ) : ( +
+ {/* File meta */} +
+
+ + {excelFile} +
+ {validated.length}개 데이터 행 검출 +
+ + {/* Previews table and validation */} +
+ 엑셀 정밀 유효값 진단 + +
+ + + + 행 + 삭제 + 자격 + 진단 내용 + 종류 + 카드번호 * + 카드이름 * + 스크립트 * + 용도 + 공개 + + + + {validated.map((row) => ( + + {row.rowNum} + + + + + + {row.status} + + + + {row.message} + + + {isWild(row.kind) ? '와일드' : '협상'} + + + handleUpdateField(row.id, 'code', e.target.value)} + /> + + + handleUpdateField(row.id, 'title', e.target.value)} + /> + + + handleUpdateField(row.id, 'script', e.target.value)} + placeholder="협상 스크립트(평문)" + /> + + + {usageCode(row.usage) === CardUsageType.NEW ? '신규' : usageCode(row.usage) === CardUsageType.REUSE ? '재' : '공통'} + + + {isShared(row.scope) ? '전체' : '개인'} + + + ))} + +
+
+ + ※ 와일드카드의 사용조건·메모는 표에 표시되지 않지만 업로드에 함께 반영됩니다. 스크립트의 굵게·색상 서식은 평문 등록 후 카드 편집에서 지정하세요. + +
+
+ )} +
+ + {/* Excel footer */} +
+ + 적격 카드: {validCount}개 // 비적격 차단: {errorCount}개 + +
+ + +
+
+ +
+
+ ); +} + +// 업로드 양식(.csv) 다운로드 — 채워 넣을 컬럼 헤더 + 예시 2행(협상/와일드). 툴바·모달이 공유한다. +export function downloadCardTemplate() { + downloadExcel( + '협상카드_업로드_양식', + [ + { header: '카드종류', value: (r) => r.kind }, + { header: '카드번호', value: (r) => r.code }, + { 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: '특정 원재료 포함 입찰에만 적용' }, + ], + ); +} diff --git a/negodata/front/src/features/cards/components/CardFormSheet.tsx b/negodata/front/src/features/cards/components/CardFormSheet.tsx index 30beb52..14e9f10 100644 --- a/negodata/front/src/features/cards/components/CardFormSheet.tsx +++ b/negodata/front/src/features/cards/components/CardFormSheet.tsx @@ -16,6 +16,7 @@ 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, '카드이름은 필수 기입 사항입니다.'), @@ -48,6 +49,7 @@ function buildDefaults( if (mode === 'edit' && card) { return { isWildcard: card.isWildcard, + isShared: card.isShared, usageType: card.usageType, code: card.code, title: card.title, @@ -61,6 +63,7 @@ function buildDefaults( const wild = activeTab === 'WILD'; return { isWildcard: wild, + isShared: false, // 기본: 개인(나만) — 전체 공용은 등록 시 명시 선택 usageType: CardUsageType.COMMON, // 기본: 공통(신규·재 모두) code: generateCardCode(wild), title: '', @@ -105,6 +108,7 @@ export function CardFormSheet({ editorScript: v.editorScript, status: v.status, isWildcard: v.isWildcard, + isShared: v.isShared, usageType: v.usageType, triggerCondition: v.triggerCondition, memo: v.memo, @@ -180,6 +184,16 @@ export function CardFormSheet({ {errors.code &&

{errors.code.message}

} + {/* 작성자(등록자) — 읽기전용, 편집 시에만. 공용 카드는 작성자 없음. */} + {mode === 'edit' && ( +
+ 작성자 + + {card?.isShared ? '공용' : card?.creatorName ?? '-'} + +
+ )} + {/* Status */}
협상 적용 여부 @@ -226,27 +240,57 @@ export function CardFormSheet({
- {/* 카드 용도(usage_type): 공통 / 신규전용 / 재전용 */} -
- 카드 용도 - ( - - )} - /> +
+ {/* 공개 범위(scope): 개인=나만 / 전체=모두 공용(user_id NULL). 등록 시에만 결정, 수정 시 고정 */} +
+ 공개 범위 + ( + + )} + /> +
+ + {/* 카드 용도(usage_type): 공통 / 신규전용 / 재전용 */} +
+ 카드 용도 + ( + + )} + /> +
{/* Title */} diff --git a/negodata/front/src/features/cards/components/CardTable.tsx b/negodata/front/src/features/cards/components/CardTable.tsx index 27368be..eac4ca7 100644 --- a/negodata/front/src/features/cards/components/CardTable.tsx +++ b/negodata/front/src/features/cards/components/CardTable.tsx @@ -20,16 +20,24 @@ export function CardTable({ data, onEdit, footer }: CardTableProps) { { header: '구분', headClassName: 'w-24', - cell: (card) => - card.isWildcard ? ( - - 와일드카드 - - ) : ( - - 협상카드 - - ), + cell: (card) => ( +
+ {card.isWildcard ? ( + + 와일드카드 + + ) : ( + + 협상카드 + + )} + {card.isShared && ( + + 전체 + + )} +
+ ), }, { header: '카드번호', @@ -88,6 +96,13 @@ export function CardTable({ data, onEdit, footer }: CardTableProps) { - ), }, + { + header: '작성자', + align: 'center', + headClassName: 'w-24', + cellClassName: 'text-muted-foreground whitespace-nowrap', + cell: (card) => (card.isShared ? '공용' : card.creatorName ?? '-'), + }, ]} /> ); diff --git a/negodata/front/src/features/cards/hooks/useCards.ts b/negodata/front/src/features/cards/hooks/useCards.ts index e2455f5..b53a5a5 100644 --- a/negodata/front/src/features/cards/hooks/useCards.ts +++ b/negodata/front/src/features/cards/hooks/useCards.ts @@ -10,6 +10,7 @@ import type { Descendant } from 'slate'; import type { ReqCreateCard } from '@/api/generated/model/reqCreateCard'; import type { ResCard } from '@/api/generated/model/resCard'; import type { NegotiationCard } from '@/types'; +import type { BulkFailure } from '@/lib/excel'; import { mapCardData, toCardStatusCode } from '../types'; import { serializeToText } from '../editor'; @@ -21,6 +22,7 @@ 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; @@ -37,6 +39,7 @@ function cardError(res: ResCard): string | null { function toReq(input: CardInput): ReqCreateCard { return { is_wildcard: input.isWildcard, + is_shared: input.isShared, usage_type: input.usageType, name: input.title, number: input.code, @@ -73,6 +76,22 @@ export function useCards(params: ListCardsParams) { await refresh(); }; + // 엑셀 일괄 등록 — 행마다 createCard(협력사와 동일 패턴). 실패 행은 code(카드번호)로 사유 수집. + // N번 refresh 방지 위해 raw createCard 를 직접 돌리고 끝에 한 번만 무효화한다. + const bulkCreate = async (rows: CardInput[]): Promise<{ failures: BulkFailure[] }> => { + const failures: BulkFailure[] = []; + for (const input of rows) { + try { + const msg = cardError(await createCard(toReq(input))); + if (msg) failures.push({ code: input.code, message: msg }); + } catch (err) { + failures.push({ code: input.code, message: err instanceof Error ? err.message : '등록 실패' }); + } + } + await refresh(); + return { failures }; + }; + // customFetch 가 본문을 그대로 주므로 cardsQuery.data 가 곧 ResCardList → .cards. const cards: NegotiationCard[] = (cardsQuery.data?.cards ?? []).map(mapCardData); const total = cardsQuery.data?.total ?? 0; // 선택 탭 기준 총건수(페이지네이션) @@ -87,6 +106,7 @@ export function useCards(params: ListCardsParams) { createCard: createCardFn, updateCard: updateCardFn, deleteCard: deleteCardFn, + bulkCreate, refresh, cardsQuery, }; diff --git a/negodata/front/src/features/cards/types.ts b/negodata/front/src/features/cards/types.ts index c885635..1f78abb 100644 --- a/negodata/front/src/features/cards/types.ts +++ b/negodata/front/src/features/cards/types.ts @@ -17,6 +17,7 @@ export function mapCardData(c: CardData): NegotiationCard { return { id: c.nego_card_id, isWildcard: c.is_wildcard ?? false, + isShared: c.is_shared ?? false, usageType: c.usage_type ?? CardUsageType.COMMON, code: c.number ?? '', title: c.name ?? '', @@ -25,6 +26,7 @@ export function mapCardData(c: CardData): NegotiationCard { status: toCardStatusLabel(c.status), triggerCondition: c.condition ?? undefined, memo: c.memo ?? undefined, + creatorName: c.creator_name ?? undefined, }; } diff --git a/negodata/front/src/features/members/components/MemberFormSheet.tsx b/negodata/front/src/features/members/components/MemberFormSheet.tsx index f94182d..55fd5a8 100644 --- a/negodata/front/src/features/members/components/MemberFormSheet.tsx +++ b/negodata/front/src/features/members/components/MemberFormSheet.tsx @@ -3,9 +3,11 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { Trash2 } from 'lucide-react'; import { showToast } from '@/lib/notify'; +import { normalizePhone } from '@/lib/phone'; import { Typography } from '@/components/ui/typography'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { PhoneInput } from '@/components/ui/phone-input'; import { Sheet } from '@/components/ui/sheet'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { @@ -47,7 +49,7 @@ function buildDefaults(mode: 'create' | 'edit', member: Member | null): FormValu passwordConfirm: '', name: member.name ?? '', email: member.email ?? '', - contactNumber: member.contact_number ?? '', + contactNumber: normalizePhone(member.contact_number ?? ''), status: member.status, }; } @@ -216,12 +218,19 @@ export function MemberFormSheet({ {/* 연락처 */}
연락처 - ( + + )} />
diff --git a/negodata/front/src/features/members/components/MemberTable.tsx b/negodata/front/src/features/members/components/MemberTable.tsx index 0d9241b..7d7ac1e 100644 --- a/negodata/front/src/features/members/components/MemberTable.tsx +++ b/negodata/front/src/features/members/components/MemberTable.tsx @@ -2,6 +2,7 @@ import { Badge } from '@/components/ui/badge'; import { DataTable } from '@/components/ui/data-table'; import { TablePagination } from '@/components/ui/table-pagination'; import { USER_ROLE_LABEL } from '@/lib/enumLabels'; +import { formatPhoneKR } from '@/lib/phone'; import { UserStatus, USER_STATUS_LABEL, type Member } from '../types'; type MemberTableProps = { @@ -66,7 +67,7 @@ export function MemberTable({ cell: (m) => (
{m.email || '-'}
-
{m.contact_number || '-'}
+
{m.contact_number ? formatPhoneKR(m.contact_number) : '-'}
), }, diff --git a/negodata/front/src/features/partners/components/ExcelUploadModal.tsx b/negodata/front/src/features/partners/components/ExcelUploadModal.tsx index 3d0dd32..1103f60 100644 --- a/negodata/front/src/features/partners/components/ExcelUploadModal.tsx +++ b/negodata/front/src/features/partners/components/ExcelUploadModal.tsx @@ -1,5 +1,6 @@ import { useMemo, useRef, useState } from 'react'; import { Upload, X, FileSpreadsheet, CheckCircle2, Trash2 } from 'lucide-react'; +import { useScrollLock } from '@/lib/useScrollLock'; import type { ReqCreateSupplier as SupplierCreate } from '@/api/generated/model/reqCreateSupplier'; import { showToast } from '@/lib/notify'; import { downloadExcel, parseCsv, type BulkFailure } from '@/lib/excel'; @@ -76,7 +77,7 @@ function toSupplierCreate(row: RawRow): SupplierCreate { code: row.code, manager_name: row.managerName, manager_email: row.managerEmail, - manager_contact_number: '010-0000-0000', + manager_contact_number: '01000000000', // 양식에 연락처 컬럼 없음 → placeholder(숫자만 저장 컨벤션) total_revenue: row.totalRevenue?.trim() ? Number(row.totalRevenue.replace(/[^0-9]/g, '')) : undefined, }; } @@ -100,6 +101,7 @@ export function downloadPartnerTemplate() { // 협력사 엑셀 일괄 업로드 모달. 파일 파싱·원본 행 state는 이 컴포넌트가 소유하고, // 검증은 렌더 시 validateRows로 파생한다. 실제 서버 등록은 onConfirm(검증된 행)으로 위임. export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUploadModalProps) { + useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금 const [excelFile, setExcelFile] = useState(null); const [rows, setRows] = useState([]); const [serverErrors, setServerErrors] = useState>({}); // 서버(DB) 거부 code→사유 @@ -199,8 +201,8 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp }; return ( -
-
+
+
{/* Modal Title */}
diff --git a/negodata/front/src/features/partners/components/PartnerFormSheet.tsx b/negodata/front/src/features/partners/components/PartnerFormSheet.tsx index 173d254..54c2e2b 100644 --- a/negodata/front/src/features/partners/components/PartnerFormSheet.tsx +++ b/negodata/front/src/features/partners/components/PartnerFormSheet.tsx @@ -1,14 +1,17 @@ -import { useForm } from 'react-hook-form'; +import { useForm, Controller } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { Trash2 } from 'lucide-react'; import type { ReqCreateSupplier as SupplierCreate } from '@/api/generated/model/reqCreateSupplier'; import type { ReqUpdateSupplier as SupplierUpdate } from '@/api/generated/model/reqUpdateSupplier'; import { showToast } from '@/lib/notify'; +import { normalizePhone } from '@/lib/phone'; import { Typography } from '@/components/ui/typography'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { PhoneInput } from '@/components/ui/phone-input'; import { Sheet } from '@/components/ui/sheet'; +import { useAuthStore } from '@/stores/auth'; import { SupplierItemsManager } from './SupplierItemsManager'; import { type Partner } from '../types'; @@ -41,7 +44,7 @@ function buildDefaults(mode: 'create' | 'edit', partner: Partner | null): FormVa code: partner.code || '', managerName: partner.manager_name || '', managerEmail: partner.manager_email || '', - managerPhone: partner.manager_contact_number || '', + managerPhone: normalizePhone(partner.manager_contact_number || ''), totalRevenue: partner.total_revenue != null ? String(partner.total_revenue) : '', }; } @@ -50,7 +53,7 @@ function buildDefaults(mode: 'create' | 'edit', partner: Partner | null): FormVa code: `PART-CORP-${Math.floor(100 + Math.random() * 900)}`, managerName: '', managerEmail: '', - managerPhone: '010-', + managerPhone: '010', // 신규 등록 시 010 프리필 → 뒷자리만 입력 totalRevenue: '', }; } @@ -68,6 +71,7 @@ export function PartnerFormSheet({ }: PartnerFormSheetProps) { const { register, + control, handleSubmit, formState: { errors, isSubmitting }, } = useForm({ @@ -75,6 +79,9 @@ export function PartnerFormSheet({ defaultValues: buildDefaults(mode, partner), }); + // 협력사 명부는 회사 공유 자원 — 파괴적 삭제는 최고관리자만(백엔드 RequireOwner 와 동일 규칙). + const isSuperAdmin = useAuthStore((s) => s.user?.role === '최고관리자'); + const onValid = async (v: FormValues) => { const common = { name: v.name, @@ -151,6 +158,14 @@ export function PartnerFormSheet({
+ {/* 작성자(등록자) — 읽기전용, 편집 시에만 */} + {mode === 'edit' && ( +
+ 작성자 + {partner?.creator_name ?? '-'} +
+ )} + {/* Manager Name */}
담당자명 @@ -180,12 +195,19 @@ export function PartnerFormSheet({ {/* Manager Phone */}
담당자 연락처 - ( + + )} /> {errors.managerPhone &&

{errors.managerPhone.message}

}
@@ -200,6 +222,8 @@ export function PartnerFormSheet({ type="button" variant="destructive" size="sm" + disabled={!isSuperAdmin} + title={isSuperAdmin ? undefined : '협력사 삭제는 최고관리자만 할 수 있습니다.'} onClick={() => { onDelete(partner.supplier_id, partner.name); onClose(); diff --git a/negodata/front/src/features/partners/components/PartnerTable.tsx b/negodata/front/src/features/partners/components/PartnerTable.tsx index 0807259..57a7b59 100644 --- a/negodata/front/src/features/partners/components/PartnerTable.tsx +++ b/negodata/front/src/features/partners/components/PartnerTable.tsx @@ -1,5 +1,6 @@ import { DataTable } from '@/components/ui/data-table'; import { TablePagination } from '@/components/ui/table-pagination'; +import { formatPhoneKR } from '@/lib/phone'; import type { Partner } from '../types'; type PartnerTableProps = { @@ -59,7 +60,7 @@ export function PartnerTable({
{part.manager_name}
- {part.manager_email} / {part.manager_contact_number} + {part.manager_email} / {part.manager_contact_number ? formatPhoneKR(part.manager_contact_number) : '-'}
), @@ -70,6 +71,12 @@ export function PartnerTable({ cellClassName: 'font-mono text-muted-foreground', cell: (part) => (part.total_revenue != null ? `₩${Number(part.total_revenue).toLocaleString()}` : '-'), }, + { + header: '작성자', + align: 'center', + cellClassName: 'text-muted-foreground whitespace-nowrap', + cell: (part) => part.creator_name ?? '-', + }, ]} /> ); diff --git a/negodata/front/src/features/products/components/ExcelUploadModal.tsx b/negodata/front/src/features/products/components/ExcelUploadModal.tsx index 3100f33..9db16af 100644 --- a/negodata/front/src/features/products/components/ExcelUploadModal.tsx +++ b/negodata/front/src/features/products/components/ExcelUploadModal.tsx @@ -1,5 +1,6 @@ import { useMemo, useRef, useState } from 'react'; import { Upload, X, FileSpreadsheet, CheckCircle2, Trash2 } from 'lucide-react'; +import { useScrollLock } from '@/lib/useScrollLock'; import type { ReqCreateItem as ItemCreate } from '@/api/generated/model/reqCreateItem'; import { showToast } from '@/lib/notify'; import { downloadExcel, parseCsv, type BulkFailure } from '@/lib/excel'; @@ -180,6 +181,7 @@ function toItemCreate(row: RawRow): ItemCreate { // 상품 엑셀 일괄 업로드 모달. 파일 파싱(목업)·원본 행 state는 이 컴포넌트가 소유하고, // 검증은 렌더 시 validateRows로 파생한다. 실제 서버 등록은 onConfirm(검증된 행)으로 위임. export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUploadModalProps) { + useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금 const [excelFile, setExcelFile] = useState(null); const [rows, setRows] = useState([]); const [serverErrors, setServerErrors] = useState>({}); // 서버(DB) 거부 code→사유 @@ -294,8 +296,8 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp }; return ( -
-
+
+
{/* Modal Title */}
diff --git a/negodata/front/src/features/products/components/PriceUpdateModal.tsx b/negodata/front/src/features/products/components/PriceUpdateModal.tsx index 2d03951..5d8b2f2 100644 --- a/negodata/front/src/features/products/components/PriceUpdateModal.tsx +++ b/negodata/front/src/features/products/components/PriceUpdateModal.tsx @@ -4,6 +4,7 @@ import { Globe, X, AlertCircle, Loader2, Cpu, RefreshCw } from 'lucide-react'; import { showToast } from '@/lib/notify'; import { Typography } from '@/components/ui/typography'; import { Button } from '@/components/ui/button'; +import { useScrollLock } from '@/lib/useScrollLock'; import type { Product } from '../types'; type PriceUpdateModalProps = { @@ -17,6 +18,7 @@ type PriceUpdateModalProps = { // 인터넷 최저가 실시간 수집 데모 모달. 크롤링 진행 state는 이 컴포넌트가 소유한다. // NOTE: 서버 미연동 — 진행 애니메이션/로그만 데모. export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose }: PriceUpdateModalProps) { + useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금 const [isCrawling, setIsCrawling] = useState(false); const [crawlingProgress, setCrawlingProgress] = useState(0); const [crawlerLogs, setCrawlerLogs] = useState([]); @@ -72,8 +74,8 @@ export function PriceUpdateModal({ open, products, selectedIds, onDone, onClose }; return ( -
-
+
+
{/* Modal Title */}
diff --git a/negodata/front/src/features/products/components/ProductFormSheet.tsx b/negodata/front/src/features/products/components/ProductFormSheet.tsx index 2cce47b..31ae1a8 100644 --- a/negodata/front/src/features/products/components/ProductFormSheet.tsx +++ b/negodata/front/src/features/products/components/ProductFormSheet.tsx @@ -12,6 +12,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Sheet } from '@/components/ui/sheet'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { useAuthStore } from '@/stores/auth'; import { type Product } from '../types'; // 폼 검증 스키마. 필수: 상품명/상품코드/단가/최저가. 나머지는 선택. @@ -129,6 +130,13 @@ export function ProductFormSheet({ const deliveryTypes = DELIVERY_TYPE_OPTIONS; + // 소유자 게이팅 — 본인이 등록한 상품 또는 최고관리자만 수정·삭제(프론트 1차 차단, 백엔드도 강제). + const myUserId = useAuthStore((s) => s.user?.userId); + const isSuperAdmin = useAuthStore((s) => s.user?.role === '최고관리자'); + const canManageOwn = !!product && (product.user_id === myUserId || isSuperAdmin); + // 저장 가능 여부 — 신규는 항상, 수정은 소유자/관리자만. + const canSave = mode === 'create' || canManageOwn; + // minPrice = 인터넷 최저가(실값) → internet_lowest_price 로 저장한다. const onValid = async (v: FormValues) => { // 기존 카테고리면 그 category_type(id) 재사용, 처음 쓰는 카테고리면 max+1 부여. @@ -244,6 +252,14 @@ export function ProductFormSheet({
+ {/* 작성자(등록자) — 읽기전용, 편집 시에만 */} + {mode === 'edit' && ( +
+ 작성자 + {product?.creator_name ?? '-'} +
+ )} +
{/* Price */}
@@ -471,6 +487,8 @@ export function ProductFormSheet({ type="button" variant="destructive" size="sm" + disabled={!canManageOwn} + title={canManageOwn ? undefined : '본인이 등록한 상품만 삭제할 수 있습니다.'} onClick={() => { onDelete(product.item_id, product.name); onClose(); @@ -483,7 +501,12 @@ export function ProductFormSheet({ -
diff --git a/negodata/front/src/features/products/components/ProductTable.tsx b/negodata/front/src/features/products/components/ProductTable.tsx index c505592..b287add 100644 --- a/negodata/front/src/features/products/components/ProductTable.tsx +++ b/negodata/front/src/features/products/components/ProductTable.tsx @@ -93,6 +93,12 @@ export function ProductTable({ cellClassName: 'font-mono font-semibold text-rose-600 dark:text-rose-400', cell: (prod) => (prod.internet_lowest_price != null ? `₩${Number(prod.internet_lowest_price).toLocaleString()}` : '-'), }, + { + header: '작성자', + align: 'center', + cellClassName: 'text-muted-foreground whitespace-nowrap', + cell: (prod) => prod.creator_name ?? '-', + }, ]} /> ); diff --git a/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx b/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx index 8e00e44..f1607f3 100644 --- a/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx +++ b/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx @@ -1,7 +1,6 @@ -import { useState, useEffect, useMemo } from 'react'; +import { useState, useMemo } from 'react'; import { X, PlusSquare, ArrowRight, Loader2, Gavel } from 'lucide-react'; import { useNavigate } from 'react-router'; -import { useGetSupplierLastType } from '@/api/generated/quotation/quotation'; import { useListItemSupplyTypes } from '@/api/generated/supplier-item/supplier-item'; import { useListItems, useGetItem } from '@/api/generated/item/item'; import { useListSuppliers } from '@/api/generated/supplier/supplier'; @@ -10,14 +9,13 @@ import { mapCardData } from '@/features/cards/types'; import { Button } from '@/components/ui/button'; import { Typography, typographyVariants } from '@/components/ui/typography'; import { cn } from '@/lib/utils'; +import { useScrollLock } from '@/lib/useScrollLock'; import { Input } from '@/components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Combobox, type ComboOption } from '@/components/ui/combobox'; import type { Product, Partner, QuotationSetting, NegotiationCard } from '../types'; import type { CreateQuotationInput } from '../hooks/useQuotations'; -import { QuotationType } from '@/api/generated/model'; import { - supplierTypeOptions, is1v1, toQuotationType, awardStrategySummary, @@ -55,6 +53,8 @@ export function QuotationCreateModal({ onCreate, onClose, }: QuotationCreateModalProps) { + // 모달 열린 동안 배경(부모) 스크롤 잠금 — 뒤 페이지가 같이 스크롤되는 것 방지. + useScrollLock(); const [step, setStep] = useState(1); const [title, setTitle] = useState(''); // 유형은 '진행 방식(협상/경매) × 대상(신규/후속)' 2축으로 받아 제출 직전 4코드로 합성한다. @@ -65,9 +65,11 @@ export function QuotationCreateModal({ const [dueDate, setDueDate] = useState(nowKstLocalInput); const [settingId, setSettingId] = useState(quotationSettings[0]?.qt_setting_id ?? ''); const [selectedCardIds, setSelectedCardIds] = useState([]); + // 선택 항목 표시데이터 캐시 — 담는 순간 이름/이메일·카드메타를 적재해, 검색어가 바뀌어 콤보 목록에서 빠져도 아래 선택 테이블이 유지되게 한다. + const [partnerDetails, setPartnerDetails] = useState>(() => new Map()); + const [cardDetails, setCardDetails] = useState>(() => new Map()); const [memo, setMemo] = useState(''); const [mdPrice, setMdPrice] = useState(''); // MD 제시가(원). 비우면 미전송 → 서버가 상품값으로 목표가 산정 - const [supplierType, setSupplierType] = useState(''); // 협력사 유형(SupplierType). 전 유형에서 입력 → 견적에 기록 const [midAction, setMidAction] = useState(DEFAULT_MID_ACTION); // 앵커~목표가 구간: 낙찰/개찰 (1:1 전용) const [overAction, setOverAction] = useState(DEFAULT_OVER_ACTION); // 목표가 초과 구간: 낙찰/개찰 (1:1 전용) const [submitting, setSubmitting] = useState(false); @@ -81,18 +83,6 @@ export function QuotationCreateModal({ : ['기본 정보', '협력사 초청', '확인·완료']; const totalSteps = steps.length; - // 협력사 유형은 전 유형에서 입력받되, 재협상(1:1)이면 선택 협력사의 직전 견적 supplier_type 을 조회해 디폴트로 채운다. - const renegoSupplierId = type === QuotationType.RENEGO ? (selectedPartnerIds[0] ?? '') : ''; - const lastTypeQuery = useGetSupplierLastType(renegoSupplierId, { - query: { enabled: !!renegoSupplierId }, - }); - const prevSupplierType = lastTypeQuery.data?.supplier_type ?? null; // 협력사 직전 견적 유형(없으면 null) - const prevQtNumber = lastTypeQuery.data?.qt_number ?? ''; - // 협력사가 정해지면 직전 견적 유형으로 디폴트(이후 사용자가 바꾸면 그 값 유지). - useEffect(() => { - setSupplierType(prevSupplierType != null ? String(prevSupplierType) : ''); - }, [renegoSupplierId, prevSupplierType]); - const navigate = useNavigate(); // ── 픽리스트 서버검색(상품/협력사/카드) — size 캡 없이 검색으로 도달. 미검색이면 부모가 넘긴 목록으로 기본 노출. @@ -141,6 +131,12 @@ export function QuotationCreateModal({ ), })); + // 선택된 협력사 표시행 — 이름/이메일은 캐시에서, 취급유형은 상품 매핑(supplyTypeBySupplier)에서 라이브로 읽는다. + const selectedPartnerRows = selectedPartnerIds.map((id) => { + const d = partnerDetails.get(id); + return { id, name: d?.name ?? id, email: d?.email ?? '' }; + }); + const cardRows = cardQ ? (cardSearch.data?.cards ?? []).map(mapCardData) : cards; const cardOptions: ComboOption[] = cardRows .filter((c) => !c.isWildcard || c.status === 'ACTIVE') @@ -159,6 +155,12 @@ export function QuotationCreateModal({
), })); + + // 선택된 카드 표시행 — 캐시에서 번호/유형/카드명을 읽어 검색어와 무관하게 유지한다. + const selectedCardRows = selectedCardIds.map((id) => { + const d = cardDetails.get(id); + return { id, code: d?.code ?? '', title: d?.title ?? id, isWildcard: d?.isWildcard ?? false }; + }); // 상품에 산정 후보가 있는지(인터넷=공통, 매입·판매=재 한정). 없으면 MD가가 유일한 후보 → 필수가 된다. const mdNum = Number(mdPrice) || 0; const hasItemCandidate = internetLowest != null || (isReType && (purchase != null || selling != null)); @@ -175,7 +177,10 @@ export function QuotationCreateModal({ // 경매는 3스텝뿐 — 협상카드 스텝(4)에 있던 상태면 마지막(3)으로 당긴다. if (next === 'auction') setStep((s) => Math.min(s, 3)); }; - const togglePartner = (id: string) => + const togglePartner = (id: string) => { + // 담는 순간 이름/이메일을 캐시에 적재 — 이후 검색어가 바뀌어 목록에서 빠져도 선택 테이블이 유지된다. + const row = supplierRows.find((r) => r.id === id); + if (row) setPartnerDetails((m) => new Map(m).set(id, { name: row.name, email: row.email })); setSelectedPartnerIds((prev) => oneToOne ? prev.includes(id) @@ -185,8 +190,12 @@ export function QuotationCreateModal({ ? prev.filter((p) => p !== id) : [...prev, id], ); - const toggleCard = (id: string) => + }; + const toggleCard = (id: string) => { + const row = cardRows.find((c) => c.id === id); + if (row) setCardDetails((m) => new Map(m).set(id, { code: row.code, title: row.title, isWildcard: row.isWildcard })); setSelectedCardIds((prev) => (prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id])); + }; const handleSubmit = async () => { if (submitting) return; @@ -208,7 +217,6 @@ export function QuotationCreateModal({ cardIds: oneToOne ? selectedCardIds : [], memo, mdPrice: mdPrice ? Number(mdPrice) : null, - supplierType: supplierType ? Number(supplierType) : null, midAction: oneToOne ? midAction : undefined, overAction: oneToOne ? overAction : undefined, }); @@ -219,7 +227,7 @@ export function QuotationCreateModal({ }; return ( -
+
{submitting && (
@@ -229,10 +237,10 @@ export function QuotationCreateModal({
)} -
+
{/* Header */} -
+
신규 견적 등록 (단계 {step}/{totalSteps}) @@ -243,7 +251,7 @@ export function QuotationCreateModal({
{/* Steps indicator — 스텝 수는 유형에 따라 3(경매)/4(협상) */} -
+
{steps.map((label, i) => { const n = i + 1; return ( @@ -257,8 +265,8 @@ export function QuotationCreateModal({ })}
- {/* Step content */} -
+ {/* Step content — 남은 높이를 채우고 내용이 길면 여기만 스크롤(작은 화면 대응) */} +
{step === 1 && (
@@ -387,7 +395,7 @@ export function QuotationCreateModal({ {step === 2 && (
협력사 초청 ({oneToOne ? '단일선택' : '다중선택'}) - {/* 서버검색 다중선택 — 각 행에 선택 상품 취급유형 배지(미매핑=미취급). oneToOne이면 togglePartner가 단일로 강제. */} + {/* 서버검색 다중선택 — 각 행에 선택 상품 상품조달유형 배지(미매핑=미정). oneToOne이면 togglePartner가 단일로 강제. */} - - {/* 협력사 유형 — 항상 노출(처음부터 입력 가능). 재협상(1:1)이면 선택 협력사의 직전 견적 값으로 자동 디폴트. */} -
- 협력사 유형 - - {prevSupplierType != null && ( - - 이전 견적({prevQtNumber}) 값으로 자동 선택됨 · 수정 가능 - - )} + {/* 선택 목록 — 검색어가 바뀌어도 담은 협력사가 유지되는 고정 테이블(취급유형은 상품×협력사 매핑). */} +
+ + 담긴 협력사 {selectedPartnerRows.length}곳 + +
)} @@ -514,13 +512,20 @@ export function QuotationCreateModal({ emptyText="협상카드가 없습니다" maxListHeight="max-h-72" /> + {/* 선택 목록 — 검색어가 바뀌어도 담은 카드가 유지되는 고정 테이블. */} +
+ + 담긴 카드 {selectedCardRows.length}장 + + +
)}
{/* Footer nav */} -
+
+ {/* 직접 낙찰 툴바 — 개찰(낙찰자 미정) 견적에서만. 표에서 협력사 하나 선택 → 낙찰 확정. */} + {showAward && ( +
+ + 개찰 · 낙찰자 미정 — 협력사를 선택해 직접 낙찰하세요. + {selectedWinner && 선택: {selectedWinner.supplier_name}} + + +
+ )} +
+ {showAward && 낙찰선택} 협력사 URL 초청메일 @@ -109,20 +173,57 @@ export function SessionsStatusTab({ {sessionViews.length === 0 && ( - + 참여 중인 협상 세션이 없습니다. (리스트가 비어 있습니다) )} - {sessionViews.map((sess) => ( - + {sessionViews.map((sess) => { + const isCandidate = sess.status === SessionStatus.DONE && sess.bid_price != null; + const isRecommended = isCandidate && sess.bid_price === lowestBid; + const isWinner = !!winnerSupplierId && sess.supplier_id === winnerSupplierId; + return ( + + {showAward && ( + + {isCandidate ? ( + + ) : ( + - + )} + + )}
- {sess.supplier_name} + + {sess.supplier_name} + @@ -148,7 +249,7 @@ export function SessionsStatusTab({
{sess.email_sent_at ? ( - + 발송됨 ) : ( @@ -205,7 +306,8 @@ export function SessionsStatusTab({ {sess.reject_delivery_type || '-'} - ))} + ); + })}
diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/TargetPriceModal.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/TargetPriceModal.tsx index 4c5a404..6002e63 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/TargetPriceModal.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/TargetPriceModal.tsx @@ -1,6 +1,7 @@ import { X, Check } from 'lucide-react'; import { Typography } from '@/components/ui/typography'; import { useGetTargetBreakdown } from '@/api/generated/quotation/quotation'; +import { useScrollLock } from '@/lib/useScrollLock'; // 세션 목표가 산정내역 모달. 후보·채택·앵커링가는 백엔드 /target-breakdown 이 산정한 값을 '표시만' 한다. // (프론트 재계산 없음 → 저장된 목표가와 항상 일치. 산정 로직은 백엔드 _candidates 단일 출처.) @@ -33,16 +34,17 @@ export function TargetPriceModal({ category, supplierTypeLabel, }: TargetPriceModalProps) { + useScrollLock(); // 모달은 열릴 때만 마운트(부모 게이트) → 배경 스크롤 잠금 const { data: bd, isLoading } = useGetTargetBreakdown(sessionId, { query: { enabled: !!sessionId } }); const candidates = bd?.candidates ?? []; return (
e.stopPropagation()} > {/* 헤더 */} diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/index.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/index.tsx index 2979168..869963f 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/index.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/index.tsx @@ -19,6 +19,7 @@ import { mapSetting, mapServerSessionView, mapServerCardView, + chainRoundState, } from '../../types'; import { supplierTypeLabel } from '@/lib/enumLabels'; import { QuotationStatus } from '@/api/generated/model'; @@ -35,6 +36,8 @@ type DrawerTab = 'status' | 'cards' | 'chat'; type QuotationDetailSheetProps = { quotation: QuotationData; onCloseQuotation: (id: string, name: string) => void; + /** 개찰(낙찰자 미정 마감) 견적을 협상현황 표에서 직접 낙찰. 성공 시 true. */ + onAward: (qtId: string, winnerSupplierId: string, winnerName: string) => Promise; /** 라운드 타임라인에서 다른 차수로 전환(같은 견적번호의 다른 견적 상세 열기). */ onSwitchRound: (qtId: string) => void; /** 마감된 견적의 다음 라운드를 수동 생성(공급사 선택). 성공 시 새 qt_id 반환. */ @@ -49,6 +52,7 @@ type QuotationDetailSheetProps = { export function QuotationDetailSheet({ quotation, onCloseQuotation, + onAward, onSwitchRound, onRegenerate, onNotify, @@ -70,9 +74,14 @@ export function QuotationDetailSheet({ ).map(mapSetting); const qtId = quotation.qt_id ?? ''; - // 초청 메일 발송은 견적 소유자만. (백엔드 스코프 도입 전까지의 1차 차단 — 본인 견적 아니면 버튼 비활성) + // 소유자 게이팅 — 견적을 바꾸는 액션(초청메일·마감·재생성·낙찰)은 '본인 견적' 또는 최고관리자만. + // 프론트 1차 차단이며, 실제 보안은 백엔드가 동일 스코프로 강제해야 함(버튼 숨김만으론 우회 가능). const myUserId = useAuthStore((s) => s.user?.userId); - const canNotify = !!myUserId && quotation.user_id === myUserId; + const isSuperAdmin = useAuthStore((s) => s.user?.role === '최고관리자'); + const canManage = !!myUserId && (quotation.user_id === myUserId || isSuperAdmin); + const canNotify = canManage; // 초청 메일 발송/재발송 + // 직접 낙찰 = 개찰(낙찰자 미정 마감) 견적에서만. 후보(투찰한 협상완료 협력사) 유무는 표에서 판정. + const canAward = canManage && chainRoundState(quotation) === 'opened'; // 협상 세션·사용 카드는 견적 단위, 채팅은 선택 세션 단위로 서버에서 읽는다. // 세션은 협상 진행으로 계속 바뀌므로 탭 복귀 시 재조회한다. 카드는 생성 후 불변이라 끄둔다. const sessionsQuery = useGetQuotationSessions(qtId, { @@ -93,7 +102,7 @@ export function QuotationDetailSheet({ const { rounds: chainRounds, isLoading: chainLoading } = useQuotationChain(quotation.number); const maxRound = chainRounds.length ? Math.max(...chainRounds.map((r) => r.round)) : (quotation.round ?? 1); const isLatestRound = (quotation.round ?? 1) >= maxRound; - const canRegenerate = !chainLoading && quotation.status === QuotationStatus.CLOSED && isLatestRound; + const canRegenerate = canManage && !chainLoading && quotation.status === QuotationStatus.CLOSED && isLatestRound; const [selectedSessionId, setSelectedSessionId] = useState(null); const effectiveSessionId = selectedSessionId ?? serverSessions[0]?.session_id ?? null; @@ -139,8 +148,8 @@ export function QuotationDetailSheet({
- {/* Header title bar (고정) */} -
+ {/* Header title bar (고정) — 아래에 상세 그리드(펼침) 또는 결과 요약 밴드(접힘)가 항상 붙는다. */} +
@@ -169,14 +178,21 @@ export function QuotationDetailSheet({ 다음 견적 재생성 )} - {/* 마감 버튼은 항상 노출하되, 마감 가능한 상태(생성·진행중·보류)가 아니면 비활성화만 한다. */} + {/* 마감 버튼은 항상 노출하되, 본인/최고관리자가 아니거나 마감 가능한 상태가 아니면 비활성화한다. */} {(() => { - const canClose = quotation.status !== QuotationStatus.CLOSED; + const alreadyClosed = quotation.status === QuotationStatus.CLOSED; + const canClose = canManage && !alreadyClosed; return (
- {/* 견적 상세 정보 — 탭과 flex 비율(헤더:탭 = 2:1)로 높이를 나눠 가지고 자체 스크롤 */} - {showHeaderCards && ( + {/* 견적 상세 정보 — 펼치면 탭과 flex 비율(헤더:탭 = 2:1)로 높이를 나눠 가지고 자체 스크롤 */} + {showHeaderCards ? (
+ ) : ( + /* 접어도 결과 요약(목표가·낙찰·절감) 밴드는 상단에 그대로 남긴다. */ +
+ +
)} {/* Tabs */} @@ -248,9 +276,12 @@ export function QuotationDetailSheet({ onNotify(qtId)} onNotifyOne={(sessionId) => onNotifySession(sessionId, qtId)} + onAward={(supplierId, supplierName) => onAward(qtId, supplierId, supplierName)} /> )} diff --git a/negodata/front/src/features/quotations/components/QuotationSettingsModal.tsx b/negodata/front/src/features/quotations/components/QuotationSettingsModal.tsx index 940a3ec..d77c06c 100644 --- a/negodata/front/src/features/quotations/components/QuotationSettingsModal.tsx +++ b/negodata/front/src/features/quotations/components/QuotationSettingsModal.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { Settings, X } from 'lucide-react'; +import { useScrollLock } from '@/lib/useScrollLock'; import { Button } from '@/components/ui/button'; import { Typography } from '@/components/ui/typography'; import { Input } from '@/components/ui/input'; @@ -22,6 +23,7 @@ export function QuotationSettingsModal({ onDelete, onClose, }: QuotationSettingsModalProps) { + useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금 const [targetMargin, setTargetMargin] = useState(''); const [cardUseCount, setCardUseCount] = useState(''); @@ -38,8 +40,8 @@ export function QuotationSettingsModal({ }; return ( -
-
+
+
diff --git a/negodata/front/src/features/quotations/hooks/useQuotations.ts b/negodata/front/src/features/quotations/hooks/useQuotations.ts index 114ac47..cf48167 100644 --- a/negodata/front/src/features/quotations/hooks/useQuotations.ts +++ b/negodata/front/src/features/quotations/hooks/useQuotations.ts @@ -14,6 +14,7 @@ import { useListQuotations, useCreateQuotation, useStopQuotation, + useAwardQuotation, useRegenerateQuotation, useNotifyQuotation, useNotifySession, @@ -39,7 +40,6 @@ export type CreateQuotationInput = { cardIds: string[]; memo: string; mdPrice?: number | null; // MD 제시가(원). 비우면 미전송 → 서버가 기존 마진식으로 목표가 산정 - supplierType?: number | null; // 협력사 유형(SupplierType). 재견적 1:1 → 견적에 기록 // 낙찰 기준 — 1:1 협상만 전송(경매는 미전송 → 서버가 mid=over=AWARD 강제). 2전략을 mid/over 로 전개해 담는다(over 항상 OPEN). midAction?: number; // PriceGateAction (앵커~목표가 처리: 낙찰/개찰) overAction?: number; // PriceGateAction (목표가 초과 처리: 협상은 항상 개찰) @@ -65,6 +65,7 @@ export function useQuotations(params: ListQuotationsParams) { const deleteSettingMutation = useDeleteSetting(); const createQuotationMutation = useCreateQuotation(); const stopQuotationMutation = useStopQuotation(); + const awardQuotationMutation = useAwardQuotation(); const regenerateQuotationMutation = useRegenerateQuotation(); const notifyQuotationMutation = useNotifyQuotation(); const notifySessionMutation = useNotifySession(); @@ -116,6 +117,29 @@ export function useQuotations(params: ListQuotationsParams) { ); }; + // 개찰(낙찰자 미정 마감) 견적을 담당자가 직접 낙찰 처리 → 서버 award_quotation(close_reason→낙찰 + 낙찰자 박제 + 작성자 알림). + // 성공 시 단건 견적·세션·알림 목록 재조회로 결과밴드('개찰'→'낙찰')와 알림함을 동기화. + const awardQuotation = async (qtId: string, winnerSupplierId: string, winnerName: string): Promise => { + try { + const res = await awardQuotationMutation.mutateAsync({ qtId, data: { winner_supplier_id: winnerSupplierId } }); + if (!res?.result?.success) { + const reason = res?.msg ?? res?.result?.desc ?? '서버 오류'; + const code = res?.result?.code; + showToast(`직접 낙찰 실패${code ? ` [${code}]` : ''}: ${reason}`, 'error'); + return false; + } + invalidateQuotations(); + queryClient.invalidateQueries({ queryKey: getGetQuotationQueryKey(qtId) }); + queryClient.invalidateQueries({ queryKey: getGetQuotationSessionsQueryKey(qtId) }); + queryClient.invalidateQueries({ queryKey: ['/v1/notification/list'] }); + showToast(`[${winnerName}] 협력사를 낙찰 처리했습니다.`, 'success'); + return true; + } catch { + showToast('직접 낙찰에 실패했습니다. 잠시 후 다시 시도해 주세요.', 'error'); + return false; + } + }; + const invalidateSettings = () => queryClient.invalidateQueries({ queryKey: getListSettingsQueryKey() }); @@ -194,7 +218,6 @@ export function useQuotations(params: ListQuotationsParams) { manager_contact_number: me?.contact || undefined, memo: input.memo.trim() || undefined, md_price: input.mdPrice && input.mdPrice > 0 ? input.mdPrice : undefined, - supplier_type: input.supplierType ?? undefined, // 낙찰 기준은 1:1 협상만 전송(모달이 미리 걸러 담음) — 경매면 미전송 → 서버가 AWARD 강제. mid_action: input.midAction ?? undefined, over_action: input.overAction ?? undefined, @@ -295,6 +318,7 @@ export function useQuotations(params: ListQuotationsParams) { total, quotationSettings, closeQuotation, + awardQuotation, addSetting, deleteSetting, createQuotation, diff --git a/negodata/front/src/lib/phone.ts b/negodata/front/src/lib/phone.ts new file mode 100644 index 0000000..53ef3d8 --- /dev/null +++ b/negodata/front/src/lib/phone.ts @@ -0,0 +1,34 @@ +// 국내 전화번호 표시/저장 헬퍼. +// - 저장(폼 상태·DB)은 숫자만(normalizePhone): 검색·중복체크·정합성 위해 정규화. +// - 화면 표시는 앞자리 규칙대로 하이픈 삽입(formatPhoneKR). + +// 숫자만 뽑아 최대 11자리로 자른다. 폼 값·DB 저장은 이 결과를 쓴다. +export function normalizePhone(value: string): string { + return value.replace(/\D/g, '').slice(0, 11); +} + +// 앞자리로 국번 자리수를 분기해 하이픈을 넣는다. 입력이 하이픈 포함이어도(레거시) 먼저 정규화. +export function formatPhoneKR(value: string): string { + const d = normalizePhone(value); + if (!d) return ''; + + // 대표번호 15xx/16xx/18xx: 4-4 (8자리) + if (d.startsWith('1')) { + if (d.length <= 4) return d; + return `${d.slice(0, 4)}-${d.slice(4, 8)}`; + } + + // 서울 02: 2-3-4(9자리) 또는 2-4-4(10자리) + if (d.startsWith('02')) { + if (d.length <= 2) return d; + if (d.length <= 5) return `${d.slice(0, 2)}-${d.slice(2)}`; + if (d.length <= 9) return `${d.slice(0, 2)}-${d.slice(2, 5)}-${d.slice(5)}`; + return `${d.slice(0, 2)}-${d.slice(2, 6)}-${d.slice(6, 10)}`; + } + + // 휴대폰 010·지역번호 031 등 3자리 국번: 3-3-4(10자리) 또는 3-4-4(11자리) + if (d.length <= 3) return d; + if (d.length <= 7) return `${d.slice(0, 3)}-${d.slice(3)}`; + if (d.length <= 10) return `${d.slice(0, 3)}-${d.slice(3, 6)}-${d.slice(6)}`; + return `${d.slice(0, 3)}-${d.slice(3, 7)}-${d.slice(7, 11)}`; +} diff --git a/negodata/front/src/pages/cards.tsx b/negodata/front/src/pages/cards.tsx index cae4944..62f767d 100644 --- a/negodata/front/src/pages/cards.tsx +++ b/negodata/front/src/pages/cards.tsx @@ -1,9 +1,11 @@ -import { Plus, BookOpen } from 'lucide-react'; +import { Plus, BookOpen, Upload, Download, FileSpreadsheet, ChevronDown } from 'lucide-react'; import { useOverlayRouter } from '@/lib/useOverlayRouter'; import { showToast } from '@/lib/notify'; import { confirm } from '@/lib/confirm'; import { PageContainer } from '@/components/layout/PageContainer'; import { SearchInput } from '@/components/layout/PageToolbar'; +import { Button } from '@/components/ui/button'; +import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu'; import { TablePagination } from '@/components/ui/table-pagination'; import { Typography } from '@/components/ui/typography'; import { useServerList } from '@/lib/useServerList'; @@ -11,6 +13,7 @@ import { useCards } from '@/features/cards/hooks/useCards'; import { useGetCard } from '@/api/generated/card/card'; import { CardTable } from '@/features/cards/components/CardTable'; import { CardFormSheet } from '@/features/cards/components/CardFormSheet'; +import { CardExcelUploadModal, downloadCardTemplate } from '@/features/cards/components/CardExcelUploadModal'; import { mapCardData, type CardTab, type NegotiationCard } from '@/features/cards/types'; import type { ListCardsParams } from '@/api/generated/model/listCardsParams'; @@ -24,13 +27,14 @@ export default function CardsPage() { page: list.page, size: list.pageSize, }; - const { cards, total, totalNego, totalWild, createCard, updateCard, deleteCard } = useCards(params); + const { cards, total, totalNego, totalWild, createCard, updateCard, deleteCard, bulkCreate } = useCards(params); const totalPages = list.totalPages(total); - // 오버레이(폼)를 쿼리스트링으로 → 딥링크·뒤로가기·새로고침 지원. + // 오버레이(폼/엑셀)를 쿼리스트링으로 → 딥링크·뒤로가기·새로고침 지원. // ?detail= 직접 접근 시 단건 API 로 받아 수정 폼을 연다(현재 페이지에 없어도 동작). - const overlay = useOverlayRouter(['new', 'detail']); + const overlay = useOverlayRouter(['new', 'detail', 'modal']); const editId = overlay.get('detail'); + const modal = overlay.get('modal'); // 'excel' | null const editQuery = useGetCard(editId ?? '', { query: { enabled: !!editId } }); const editing: NegotiationCard | null = editQuery.data?.card ? mapCardData(editQuery.data.card) : null; const formMode: 'create' | 'edit' = editId ? 'edit' : 'create'; @@ -65,19 +69,39 @@ export default function CardsPage() {
협상카드 및 와일드카드 관리 - 본인이 등록한 카드만 조회·관리할 수 있습니다. + 본인 카드와 전체(공용) 카드를 함께 조회·관리합니다. 전체 카드는 모든 사용자에게 공유됩니다.
- +
+ + }> + + 엑셀업로드 + + + + overlay.open('modal', 'excel')}> + + 일괄 업로드 + + + + 양식 다운로드 + + + + + +
{/* Primary Navigation Tab */} @@ -137,6 +161,14 @@ export default function CardsPage() { onClose={overlay.close} /> )} + + {modal === 'excel' && ( + + )} ); } diff --git a/negodata/front/src/pages/notifications.tsx b/negodata/front/src/pages/notifications.tsx index 7071945..04e6c51 100644 --- a/negodata/front/src/pages/notifications.tsx +++ b/negodata/front/src/pages/notifications.tsx @@ -164,8 +164,9 @@ function render(n: NotificationData): { icon: ReactNode; tone: string; event: st case NotificationType.CREATED: return { icon: , tone: 'text-sky-600', event: '견적 생성', line: name, number }; case NotificationType.SUCCESS: + // 자동 낙찰과 담당자 직접 낙찰(data.manual)은 같은 SUCCESS — 문구로만 '직접'을 구분한다. return { - icon: , tone: 'text-emerald-600', event: '견적 낙찰', + icon: , tone: 'text-emerald-600', event: d.manual ? '견적 낙찰 · 직접' : '견적 낙찰', line: `${name} — ${d.winner_name ?? '-'} ${Number(d.winner_price ?? 0).toLocaleString()}원`, number, }; diff --git a/negodata/front/src/pages/quotation.tsx b/negodata/front/src/pages/quotation.tsx index fa9e641..1e735c8 100644 --- a/negodata/front/src/pages/quotation.tsx +++ b/negodata/front/src/pages/quotation.tsx @@ -40,6 +40,7 @@ export default function QuotationPage() { total, quotationSettings, closeQuotation, + awardQuotation, addSetting, deleteSetting, createQuotation, @@ -178,6 +179,7 @@ export default function QuotationPage() { key={activeQuotation.qt_id} quotation={activeQuotation} onCloseQuotation={closeQuotation} + onAward={awardQuotation} onSwitchRound={(qtId) => overlay.open('detail', qtId, { replace: true })} onRegenerate={regenerateQuotation} onNotify={notifyQuotation}