From 51d07bb77901266bfa5d88cc2735b2ee649e0d06 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Fri, 24 Jul 2026 14:18:52 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20negodata:=20=EC=9E=AC=ED=98=91?= =?UTF-8?q?=EC=83=81=20=EC=86=8C=EC=9C=A0=EA=B6=8C=20=EA=B2=8C=EC=9D=B4?= =?UTF-8?q?=ED=8C=85=C2=B7=EC=B2=98=EB=A6=AC=EB=8B=B4=EB=8B=B9=EC=9E=90?= =?UTF-8?q?=C2=B7=EC=83=81=EC=84=B8=20=EB=A7=81=ED=81=AC=C2=B7=EC=9E=AC?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=ED=83=80=EC=9D=B4=ED=8B=80=C2=B7=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=ED=86=A0=EC=8A=A4=ED=8A=B8(=EC=9D=BD=EC=9D=8CAPI)?= =?UTF-8?q?=C2=B7=EC=9E=AC=ED=98=91=EC=83=81=EC=9A=94=EC=B2=AD=20=ED=91=9C?= =?UTF-8?q?=EA=B8=B0=C2=B7=EC=BB=A4=EC=8A=A4=ED=85=80=ED=95=84=EB=93=9C=20?= =?UTF-8?q?select?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- negodata/backend/common/enums.py | 15 + negodata/backend/crud/renegotiation_crud.py | 132 +++++++ negodata/backend/router/router.py | 2 + .../router/v1/renegotiation/__init__.py | 0 .../router/v1/renegotiation/protocol.py | 56 +++ .../router/v1/renegotiation/renegotiation.py | 47 +++ .../backend/services/quotation_service.py | 13 +- .../backend/services/renegotiation_service.py | 170 ++++++++ negodata/backend/tests/test_renegotiation.py | 364 ++++++++++++++++++ negodata/backend/tests/test_scheduler.py | 4 +- negodata/docs/imk-test-coverage.md | 68 ++++ .../front/src/api/generated/model/index.ts | 17 + .../api/generated/model/listRequestsParams.ts | 22 ++ .../api/generated/model/notificationType.ts | 1 + .../api/generated/model/renegotiationData.ts | 39 ++ .../model/renegotiationDataBidPrice.ts | 8 + .../model/renegotiationDataCloseReason.ts | 8 + .../model/renegotiationDataDecidedAt.ts | 8 + .../model/renegotiationDataDecidedByName.ts | 8 + .../model/renegotiationDataDesiredPrice.ts | 8 + .../generated/model/renegotiationDataMemo.ts | 8 + .../model/renegotiationDataNextQuotationId.ts | 8 + .../model/renegotiationDataTargetPrice.ts | 8 + .../model/reqApproveRenegotiation.ts | 16 + .../generated/model/reqRejectRenegotiation.ts | 14 + .../model/resRenegotiationDecision.ts | 17 + .../model/resRenegotiationDecisionMsg.ts | 8 + ...resRenegotiationDecisionNextQuotationId.ts | 8 + .../generated/model/resRenegotiationList.ts | 18 + .../model/resRenegotiationListMsg.ts | 8 + .../generated/renegotiation/renegotiation.ts | 265 +++++++++++++ negodata/front/src/app/provider.tsx | 2 +- negodata/front/src/app/router.tsx | 2 + .../src/components/layout/ActionBanner.tsx | 107 +++++ .../components/layout/AuthenticatedLayout.tsx | 1 + .../front/src/components/layout/Layout.tsx | 9 +- .../cards/components/CardFormSheet.tsx | 4 +- .../onboarding/OnboardingGuideModal.tsx | 4 +- .../partners/components/PartnerFormSheet.tsx | 4 +- .../partners/components/PartnerTable.tsx | 7 +- .../products/components/ProductFormSheet.tsx | 4 +- .../components/QuotationDetailSheet/index.tsx | 4 +- .../components/RenegotiationReviewSheet.tsx | 147 +++++++ .../components/RenegotiationTable.tsx | 122 ++++++ .../front/src/features/renegotiation/types.ts | 23 ++ .../features/settings/CustomFieldInputs.tsx | 14 + .../src/features/settings/SettingsView.tsx | 90 +++-- .../front/src/features/settings/catalog.ts | 4 +- negodata/front/src/pages/notifications.tsx | 16 +- negodata/front/src/pages/partners.tsx | 8 +- negodata/front/src/pages/renegotiation.tsx | 113 ++++++ negodata/front/src/pages/statistics.tsx | 3 +- negodata/front/src/stores/auth.ts | 2 + negodata/front/src/types.ts | 2 +- 54 files changed, 2002 insertions(+), 58 deletions(-) create mode 100644 negodata/backend/crud/renegotiation_crud.py create mode 100644 negodata/backend/router/v1/renegotiation/__init__.py create mode 100644 negodata/backend/router/v1/renegotiation/protocol.py create mode 100644 negodata/backend/router/v1/renegotiation/renegotiation.py create mode 100644 negodata/backend/services/renegotiation_service.py create mode 100644 negodata/backend/tests/test_renegotiation.py create mode 100644 negodata/docs/imk-test-coverage.md create mode 100644 negodata/front/src/api/generated/model/listRequestsParams.ts create mode 100644 negodata/front/src/api/generated/model/renegotiationData.ts create mode 100644 negodata/front/src/api/generated/model/renegotiationDataBidPrice.ts create mode 100644 negodata/front/src/api/generated/model/renegotiationDataCloseReason.ts create mode 100644 negodata/front/src/api/generated/model/renegotiationDataDecidedAt.ts create mode 100644 negodata/front/src/api/generated/model/renegotiationDataDecidedByName.ts create mode 100644 negodata/front/src/api/generated/model/renegotiationDataDesiredPrice.ts create mode 100644 negodata/front/src/api/generated/model/renegotiationDataMemo.ts create mode 100644 negodata/front/src/api/generated/model/renegotiationDataNextQuotationId.ts create mode 100644 negodata/front/src/api/generated/model/renegotiationDataTargetPrice.ts create mode 100644 negodata/front/src/api/generated/model/reqApproveRenegotiation.ts create mode 100644 negodata/front/src/api/generated/model/reqRejectRenegotiation.ts create mode 100644 negodata/front/src/api/generated/model/resRenegotiationDecision.ts create mode 100644 negodata/front/src/api/generated/model/resRenegotiationDecisionMsg.ts create mode 100644 negodata/front/src/api/generated/model/resRenegotiationDecisionNextQuotationId.ts create mode 100644 negodata/front/src/api/generated/model/resRenegotiationList.ts create mode 100644 negodata/front/src/api/generated/model/resRenegotiationListMsg.ts create mode 100644 negodata/front/src/api/generated/renegotiation/renegotiation.ts create mode 100644 negodata/front/src/components/layout/ActionBanner.tsx create mode 100644 negodata/front/src/features/renegotiation/components/RenegotiationReviewSheet.tsx create mode 100644 negodata/front/src/features/renegotiation/components/RenegotiationTable.tsx create mode 100644 negodata/front/src/features/renegotiation/types.ts create mode 100644 negodata/front/src/pages/renegotiation.tsx diff --git a/negodata/backend/common/enums.py b/negodata/backend/common/enums.py index 2a8befb..4bf0fe7 100644 --- a/negodata/backend/common/enums.py +++ b/negodata/backend/common/enums.py @@ -229,6 +229,21 @@ class NotificationType(CodeEnum): REGENERATED = 2 # 다음 라운드 자동 생성(동가/미참여) — KTC 대응어 없어 negodata 유지 FAILURE = 3 # 결렬: 낙찰 없이 마감(거절/부분/한도) — KTC FAILURE CREATED = 4 # 견적 생성됨(작성 직후) — 생성 알림 + RENEGO_REQUESTED = 5 # 공급사가 재협상 요청(IMK #15) — 담당자가 승인/반려할 때까지 배너로 상시 노출 + + +# 처리(승인·반려)하기 전에는 사라지지 않고 화면 하단 배너로 상시 노출되는 알림 유형. +# 단순 통지(SUCCESS/FAILURE 등)와 달리 담당자의 액션을 기다리는 건이라 읽음 처리만으로 닫지 않는다. +ACTION_REQUIRED_NOTIFICATIONS = {NotificationType.RENEGO_REQUESTED} + + +class RenegotiationStatus(CodeEnum): + """sessions.custom.renegotiation.status — 공급사 재협상 요청 상태(IMK #15). 전용 테이블 없이 JSONB 에 둔다.""" + + PENDING = 1 # 접수, 담당자 심사 대기 + APPROVED = 2 # 승인 — 다음 라운드 생성 완료 + REJECTED = 3 # 반려 — 사유 기록 + CANCELED = 4 # 공급사가 철회 class ChatSender(CodeEnum): diff --git a/negodata/backend/crud/renegotiation_crud.py b/negodata/backend/crud/renegotiation_crud.py new file mode 100644 index 0000000..c0ff984 --- /dev/null +++ b/negodata/backend/crud/renegotiation_crud.py @@ -0,0 +1,132 @@ +from abc import ABC, abstractmethod +from typing import Optional, Tuple + +from sqlalchemy import String, and_, cast, func, select, text, update +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import aliased + +from common.database.db_session_manager import DB_SESSION_MNG +from common.database.model.models import items, quotations, sessions, suppliers, users +from common.enums import ErrorType +from common.logger import LOG + +# 재협상 요청은 전용 테이블 없이 sessions.custom.renegotiation 에 들어간다(IMK #15). +# 조회는 세션을 견적·상품·공급사와 조인하면서 JSONB 조건으로 거른다. +_RENEGO = sessions.custom["renegotiation"] + + +class IRenegotiationCRUD(ABC): + @abstractmethod + async def list_requests(self, cdb: AsyncSession, company_id, status: Optional[int], skip: int, limit: int, owner_user_id=None) -> Tuple[ErrorType, list, int]: + pass + + @abstractmethod + async def get_request(self, cdb: AsyncSession, company_id, session_id) -> Tuple[ErrorType, Optional[tuple]]: + pass + + @abstractmethod + async def merge_custom(self, cdb: AsyncSession, session_id, patch: dict) -> ErrorType: + pass + + +class RenegotiationCRUD(IRenegotiationCRUD): + @staticmethod + def _base_query(company_id, status: Optional[int], owner_user_id=None): + # 회사 스코프는 견적 작성자(users.company_id)로 건다 — quotations 에 company_id 컬럼이 없다. + # owner_user_id 가 오면(일반관리자) 자기 견적만 — 본인 견적의 재협상 요청만 보고 처리한다. OWNER 는 None(회사 전체). + conds = [ + sessions.deleted == False, # noqa: E712 + users.company_id == company_id, + _RENEGO.isnot(None), + ] + if owner_user_id is not None: + conds.append(quotations.user_id == owner_user_id) + if status is not None: + conds.append(cast(_RENEGO["status"].astext, String) == str(status)) + return and_(*conds) + + @staticmethod + def _select(): + # 승인/반려한 담당자 이름 — custom.renegotiation.decided_by(user_id) 로 users 를 한 번 더(별칭) 조인. + decider = aliased(users) + return ( + select( + sessions.session_id, + sessions.quotation_id, + sessions.supplier_id, + sessions.target_price, + sessions.bid_price, + sessions.custom, + quotations.number, + quotations.round, + quotations.name, + quotations.close_reason, + items.name, + suppliers.name, + decider.name, + users.user_id, # 견적 작성자(소유자) — 승인/반려 소유권 게이팅용 (row[13]) + users.name, # 견적 담당자(작성자) 이름 — 리스트 표시용 (row[14]) + sessions.item_id, # 상품 링크용 (row[15]) + ) + .select_from(sessions) + .join(quotations, quotations.qt_id == sessions.quotation_id) + .join(users, users.user_id == quotations.user_id) + .outerjoin(items, items.item_id == sessions.item_id) + .outerjoin(suppliers, suppliers.supplier_id == sessions.supplier_id) + .outerjoin(decider, cast(decider.user_id, String) == _RENEGO["decided_by"].astext) + ) + + async def list_requests(self, cdb: AsyncSession, company_id, status: Optional[int], skip: int, limit: int, owner_user_id=None) -> Tuple[ErrorType, list, int]: + try: + where = self._base_query(company_id, status, owner_user_id) + + cnt_err, cnt_rows = await DB_SESSION_MNG.execute( + cdb, + select(func.count()) + .select_from(sessions) + .join(quotations, quotations.qt_id == sessions.quotation_id) + .join(users, users.user_id == quotations.user_id) + .where(where), + ) + if cnt_err != ErrorType.SUCCESS: + return cnt_err, [], 0 + total = int(cnt_rows[0] or 0) if cnt_rows else 0 + + # 요청 시각 내림차순 — JSONB 텍스트지만 ISO8601 이라 사전순 = 시간순. + err, rows = await DB_SESSION_MNG.execute( + cdb, + self._select().where(where).order_by(_RENEGO["requested_at"].astext.desc()).offset(skip).limit(limit), + ) + if err != ErrorType.SUCCESS: + return err, [], 0 + return ErrorType.SUCCESS, list(rows), total + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, [], 0 + + async def get_request(self, cdb: AsyncSession, company_id, session_id) -> Tuple[ErrorType, Optional[tuple]]: + try: + err, rows = await DB_SESSION_MNG.execute( + cdb, + self._select().where(and_(self._base_query(company_id, None), sessions.session_id == session_id)).limit(1), + ) + if err != ErrorType.SUCCESS: + return err, None + return ErrorType.SUCCESS, rows[0] if rows else None + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, None + + async def merge_custom(self, cdb: AsyncSession, session_id, patch: dict) -> ErrorType: + # 부가정보와 같은 컬럼을 쓰므로 통째로 덮지 않고 병합한다. + try: + query = ( + update(sessions) + .where(sessions.session_id == session_id) + .values(custom=func.coalesce(sessions.custom, cast(text("'{}'"), JSONB)).op("||")(cast(patch, JSONB))) + ) + return await DB_SESSION_MNG.add(cdb, query) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED diff --git a/negodata/backend/router/router.py b/negodata/backend/router/router.py index 4338509..8a2b4dc 100644 --- a/negodata/backend/router/router.py +++ b/negodata/backend/router/router.py @@ -22,6 +22,7 @@ import router.v1.quotation_setting.quotation_setting import router.v1.dashboard.dashboard import router.v1.statistics.statistics import router.v1.notification.notification +import router.v1.renegotiation.renegotiation API_SERVER_START_TIME = GTime.UTCStr() @@ -79,3 +80,4 @@ app.include_router(router.v1.quotation_setting.quotation_setting.router) app.include_router(router.v1.dashboard.dashboard.router) app.include_router(router.v1.statistics.statistics.router) app.include_router(router.v1.notification.notification.router) +app.include_router(router.v1.renegotiation.renegotiation.router) diff --git a/negodata/backend/router/v1/renegotiation/__init__.py b/negodata/backend/router/v1/renegotiation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/negodata/backend/router/v1/renegotiation/protocol.py b/negodata/backend/router/v1/renegotiation/protocol.py new file mode 100644 index 0000000..0cd9a87 --- /dev/null +++ b/negodata/backend/router/v1/renegotiation/protocol.py @@ -0,0 +1,56 @@ +from typing import List, Optional + +from pydantic import Field + +from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol + + +class RenegotiationProtocol(WebPacketProtocol): + pass + + +class RenegotiationData(RenegotiationProtocol): + session_id: str = "" + quotation_id: str = "" + qt_number: str = "" + qt_round: int = 0 + qt_name: str = "" + item_id: str = "" + item_name: str = "" + supplier_id: str = "" + supplier_name: str = "" + owner_name: str = "" # 견적 담당자(작성자) 이름 — 누구 견적인지 리스트 표시용 + status: int = 0 + reason: str = "" + desired_price: Optional[int] = None + requested_at: str = "" + decided_at: Optional[str] = None + decided_by_name: Optional[str] = None + memo: Optional[str] = None + next_quotation_id: Optional[str] = None + close_reason: Optional[int] = None + target_price: Optional[int] = None + bid_price: Optional[int] = None + can_act: bool = True # 조회자가 이 요청을 승인/반려할 수 있는지(견적 소유자∪OWNER). 리스트는 전체가 보이되 처리는 이걸로 게이팅 + + +class Res_RenegotiationList(Res_WebPacketProtocol): + requests: List[RenegotiationData] = [] + total: int = 0 + page: int = 0 + size: int = 0 + + +class Req_ApproveRenegotiation(RenegotiationProtocol): + supplier_ids: List[str] = Field(default_factory=list, description="다음 라운드에 함께 넣을 공급사. 비우면 요청자만") + memo: str = Field("", max_length=255, description="승인 메모") + + +class Req_RejectRenegotiation(RenegotiationProtocol): + memo: str = Field("", max_length=255, description="반려 사유 — 공급사에게 그대로 노출된다") + + +class Res_RenegotiationDecision(Res_WebPacketProtocol): + session_id: str = "" + status: int = 0 + next_quotation_id: Optional[str] = None diff --git a/negodata/backend/router/v1/renegotiation/renegotiation.py b/negodata/backend/router/v1/renegotiation/renegotiation.py new file mode 100644 index 0000000..4d0576b --- /dev/null +++ b/negodata/backend/router/v1/renegotiation/renegotiation.py @@ -0,0 +1,47 @@ +from typing import Optional + +from fastapi import APIRouter, Depends, Path, Query + +from common.models.gmodel import PageParams, UserInfo +from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse +from services.renegotiation_service import RenegotiationService +from .protocol import ( + Req_ApproveRenegotiation, + Req_RejectRenegotiation, + Res_RenegotiationDecision, + Res_RenegotiationList, +) + +router = APIRouter(prefix="/v1/renegotiation", tags=["Renegotiation"], responses={404: {"description": "Not found"}}) + + +@router.get(path="/list", response_model=Res_RenegotiationList, summary="재협상 요청 현황") +async def list_requests( + service: RenegotiationService = Depends(), + user_info: UserInfo = Depends(IsValidAccessToken), + status: Optional[int] = Query(None, description="요청 상태(1=심사중 2=승인 3=반려 4=철회). 미지정 시 전체"), + pg: PageParams = Depends(), +): + return RemoveNoneResponse(await service.list_requests(user_info.company_id, user_info.user_id, user_info.role, status, pg)) + + +@router.post(path="/{session_id}/approve", response_model=Res_RenegotiationDecision, summary="재협상 승인") +async def approve( + req: Req_ApproveRenegotiation, + session_id: str = Path(..., description="요청이 달린 협상 세션 uuid"), + service: RenegotiationService = Depends(), + user_info: UserInfo = Depends(IsValidAccessToken), +): + return RemoveNoneResponse( + await service.approve(user_info.company_id, user_info.user_id, user_info.role, session_id, req) + ) + + +@router.post(path="/{session_id}/reject", response_model=Res_RenegotiationDecision, summary="재협상 반려") +async def reject( + req: Req_RejectRenegotiation, + session_id: str = Path(..., description="요청이 달린 협상 세션 uuid"), + service: RenegotiationService = Depends(), + user_info: UserInfo = Depends(IsValidAccessToken), +): + return RemoveNoneResponse(await service.reject(user_info.company_id, user_info.user_id, user_info.role, session_id, req)) diff --git a/negodata/backend/services/quotation_service.py b/negodata/backend/services/quotation_service.py index a551107..971447c 100644 --- a/negodata/backend/services/quotation_service.py +++ b/negodata/backend/services/quotation_service.py @@ -311,7 +311,7 @@ class QuotationService: ) return res - async def regenerate_next_round(self, original_qt_id: uuid.UUID, supplier_ids: list) -> Res_CreateQuotation: + async def regenerate_next_round(self, original_qt_id: uuid.UUID, supplier_ids: list, regen_label: Optional[str] = None) -> Res_CreateQuotation: """[마감 후속] 결판 안 난 견적의 '다음 라운드'를 새로 만든다. 플로우: @@ -356,9 +356,10 @@ class QuotationService: ) base_round = chain_max if (_e == ErrorType.SUCCESS and chain_max) else original.round next_round = base_round + 1 - # 이름에 '(N차)' 표기. 원래 이름 기준(기존 '(M차)' 표기는 떼고 새로) + name 컬럼 50자 제한 보호. - suffix = f" ({next_round}차)" - base_name = re.sub(r"\s*\(\d+차\)\s*$", "", original.name or "")[: 50 - len(suffix)] + # 이름 접미사: 재협상 승인 재생성은 '(재협상 요청 재생성)', 그 외 재생성은 '(N차)'. + # 원래 이름의 기존 접미사(차수/재협상)는 떼고 새로 붙인다 + name 컬럼 50자 제한 보호. + suffix = f" ({regen_label})" if regen_label else f" ({next_round}차)" + base_name = re.sub(r"\s*\((?:\d+차|재협상[^)]*)\)\s*$", "", original.name or "")[: 50 - len(suffix)] return await self._build_quotation( user_id=str(original.user_id), qt_setting_id=original.qt_setting_id, @@ -697,7 +698,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, user_id=None, role=None) -> Res_CreateQuotation: + async def regenerate_quotation(self, qt_id: str, company_id, supplier_ids: list, user_id=None, role=None, regen_label: Optional[str] = None) -> Res_CreateQuotation: """[프론트] 마감된 견적을 골라 수동으로 다음 라운드를 생성한다. 크론/수동마감의 자동 재생성과 달리 사유·체인 한도 판정 없이, 프론트가 고른 공급사로 바로 만든다. 상품·기간·견적번호·카드버전은 원 견적에서 이어받는다(regenerate_next_round).""" @@ -733,7 +734,7 @@ class QuotationService: res.msg = "마지막 차수의 견적에서만 다음 라운드를 생성할 수 있습니다." return res - return await self.regenerate_next_round(qt_uuid, supplier_ids) + return await self.regenerate_next_round(qt_uuid, supplier_ids, regen_label=regen_label) async def stop_quotation(self, qt_id: str, company_id=None, user_id=None, role=None) -> Res_Quotation: """[프론트] 수동 견적마감. 크론과 똑같은 마감 판정(close_and_decide)을 탄다 diff --git a/negodata/backend/services/renegotiation_service.py b/negodata/backend/services/renegotiation_service.py new file mode 100644 index 0000000..2941145 --- /dev/null +++ b/negodata/backend/services/renegotiation_service.py @@ -0,0 +1,170 @@ +import uuid +from typing import Optional + +from fastapi import Depends + +from common.authz import is_owner_or_admin +from common.database.db_session_manager import DB_SESSION_MNG +from common.database.model.models import sessions +from common.enums import DBWRType, ErrorType, RenegotiationStatus +from common.logger import LOG +from common.models.gmodel import PageParams +from common.utils.gtime import GTime +from crud.renegotiation_crud import IRenegotiationCRUD, RenegotiationCRUD +from router.v1.renegotiation.protocol import ( + RenegotiationData, + Req_ApproveRenegotiation, + Req_RejectRenegotiation, + Res_RenegotiationDecision, + Res_RenegotiationList, +) +from services.quotation_service import QuotationService + + +class RenegotiationService: + """공급사 재협상 요청 심사(IMK #15). + + 요청 자체는 공급사 포털이 sessions.custom.renegotiation 에 기록한다. 여기서는 조회와 승인/반려만 한다. + 승인은 새 로직이 아니라 기존 수동 재생성(regenerate_quotation)을 그대로 호출한다 — 라운드 생성 규칙을 한 곳에 둔다. + """ + + def __init__( + self, + crud: IRenegotiationCRUD = Depends(RenegotiationCRUD), + quotation_service: QuotationService = Depends(), + ): + self.crud = crud + self.quotation_service = quotation_service + + async def list_requests(self, company_id: str, user_id: str, role: int, status: Optional[int], pg: PageParams) -> Res_RenegotiationList: + res = Res_RenegotiationList(page=pg.page, size=pg.size) + # 리스트는 회사 전체가 보인다(현황 공유). 처리 권한은 항목별 can_act 로 내려준다. + err, rows, total = await DB_SESSION_MNG.execute_lambda( + sessions.DBType(), + DBWRType.DB_READ.value, + lambda s: self.crud.list_requests(s, uuid.UUID(company_id), status, pg.skip, pg.size), + ) + if err != ErrorType.SUCCESS: + res.result.SetResult(err) + return res + res.requests = [self._to_data(r, user_id, role) for r in rows] + res.total = total + return res + + async def approve(self, company_id: str, user_id: str, role: int, session_id: str, req: Req_ApproveRenegotiation) -> Res_RenegotiationDecision: + """승인 → 원 견적의 다음 라운드 생성. 요청 공급사는 항상 포함한다.""" + res = Res_RenegotiationDecision() + err, row, current = await self._load_pending(company_id, session_id) + if err != ErrorType.SUCCESS: + res.result.SetResult(err) + return res + # 승인은 견적 작성자 본인 또는 최고관리자만 — 일반관리자는 남의 견적 처리 불가. + if not is_owner_or_admin(row[13], user_id, role): + res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN) + return res + + supplier_ids = list({str(row[2]), *(req.supplier_ids or [])}) + created = await self.quotation_service.regenerate_quotation( + str(row[1]), company_id, supplier_ids, user_id=user_id, role=role, regen_label="재협상 요청 재생성" + ) + if created.result.success is False: + res.result.SetResult(ErrorType.FAIL) + res.msg = created.msg or "다음 라운드 생성에 실패했습니다." + return res + + patch = { + **current, + "status": RenegotiationStatus.APPROVED.value, + "decided_by": user_id, + "decided_at": GTime.UTC().isoformat(), + "memo": (req.memo or "").strip() or None, + "next_quotation_id": str(created.qt_id) if created.qt_id else None, + } + err = await self._save(session_id, patch) + if err != ErrorType.SUCCESS: + # 라운드는 이미 생겼는데 상태만 못 남긴 경우 — 로그로 남기고 성공으로 반환한다(재승인은 막힌다). + LOG.e_no_callstack(f"[renego] 승인 상태 기록 실패 session={session_id} next_qt={created.qt_id}") + + res.session_id = session_id + res.status = RenegotiationStatus.APPROVED.value + res.next_quotation_id = str(created.qt_id) if created.qt_id else None + return res + + async def reject(self, company_id: str, user_id: str, role: int, session_id: str, req: Req_RejectRenegotiation) -> Res_RenegotiationDecision: + res = Res_RenegotiationDecision() + err, row, current = await self._load_pending(company_id, session_id) + if err != ErrorType.SUCCESS: + res.result.SetResult(err) + return res + # 반려도 승인과 같은 소유권 규칙 — 견적 작성자 본인 또는 최고관리자만. + if not is_owner_or_admin(row[13], user_id, role): + res.result.SetResult(ErrorType.ACCOUNT_FORBIDDEN) + return res + + patch = { + **current, + "status": RenegotiationStatus.REJECTED.value, + "decided_by": user_id, + "decided_at": GTime.UTC().isoformat(), + "memo": (req.memo or "").strip() or None, + } + err = await self._save(session_id, patch) + if err != ErrorType.SUCCESS: + res.result.SetResult(err) + return res + + res.session_id = session_id + res.status = RenegotiationStatus.REJECTED.value + return res + + async def _load_pending(self, company_id: str, session_id: str): + """심사 대상(대기 중) 요청 로드. (err, row, renegotiation dict)""" + try: + sid = uuid.UUID(session_id) + except (ValueError, TypeError): + return ErrorType.INVALID_REQUEST_DATA, None, {} + err, row = await DB_SESSION_MNG.execute_lambda( + sessions.DBType(), + DBWRType.DB_READ.value, + lambda s: self.crud.get_request(s, uuid.UUID(company_id), sid), + ) + if err != ErrorType.SUCCESS or row is None: + return ErrorType.QUOTATION_NOT_FOUND, None, {} + current = (row[5] or {}).get("renegotiation") or {} + if current.get("status") != RenegotiationStatus.PENDING.value: + return ErrorType.INVALID_REQUEST_DATA, None, {} + return ErrorType.SUCCESS, row, current + + async def _save(self, session_id: str, patch: dict) -> ErrorType: + return await DB_SESSION_MNG.execute_lambda_run( + [sessions.DBType()], + [lambda s: self.crud.merge_custom(s, uuid.UUID(session_id), {"renegotiation": patch})], + ) + + @staticmethod + def _to_data(row, user_id=None, role=None) -> RenegotiationData: + r = (row[5] or {}).get("renegotiation") or {} + return RenegotiationData( + can_act=is_owner_or_admin(row[13], user_id, role) if user_id is not None else True, + session_id=str(row[0]), + quotation_id=str(row[1]), + supplier_id=str(row[2]), + target_price=row[3], + bid_price=row[4], + qt_number=row[6] or "", + qt_round=row[7] or 0, + qt_name=row[8] or "", + close_reason=row[9], + item_id=str(row[15]) if row[15] else "", + item_name=row[10] or "", + supplier_name=row[11] or "", + owner_name=row[14] or "", + status=r.get("status") or 0, + reason=r.get("reason") or "", + desired_price=r.get("desired_price"), + requested_at=r.get("requested_at") or "", + decided_at=r.get("decided_at"), + decided_by_name=row[12] or None, + memo=r.get("memo"), + next_quotation_id=r.get("next_quotation_id"), + ) diff --git a/negodata/backend/tests/test_renegotiation.py b/negodata/backend/tests/test_renegotiation.py new file mode 100644 index 0000000..ed3dd0b --- /dev/null +++ b/negodata/backend/tests/test_renegotiation.py @@ -0,0 +1,364 @@ +"""공급사 재협상 요청 심사(IMK #15) — RenegotiationService 단위 테스트. + +요청 자체는 공급사 포털이 sessions.custom.renegotiation 에 기록한다(여기선 조회·승인·반려만). +승인은 새 로직이 아니라 기존 regenerate_quotation 을 호출하므로, 라운드 생성 machinery 는 +스텁 QuotationService 로 격리하고 #15 고유 계약만 본다: + · 목록 = 회사 스코프 + 상태 필터 (남의 회사 요청은 안 보임) + · 승인 = 대기 요청만 → APPROVED 박제 + next_quotation_id 저장, 요청 공급사는 항상 재생성에 포함 + · 승인 재생성 실패 → 상태 PENDING 유지(성급히 APPROVED 로 넘기지 않음) + · 반려 = 대기 요청만 → REJECTED + 사유(memo) 저장 + · 대기 아닌 요청(이미 승인/반려)엔 승인·반려 재시도 거부(멱등 가드) + +세션의 custom.renegotiation 은 포털에서만 생기는 값이라 SQL 로 직접 넣는다. +""" +import json +import uuid +from datetime import datetime + +import pytest_asyncio +from sqlalchemy import text + +from common.enums import ( + CloseReason, + ErrorType, + QuotationStatus, + QuotationType, + RenegotiationStatus, + SessionStatus, + UserRole, +) +from common.models.gmodel import PageParams +from crud.renegotiation_crud import RenegotiationCRUD +from router.v1.quotation.protocol import Res_CreateQuotation +from services.renegotiation_service import RenegotiationService + +PAST = datetime(2020, 1, 1) +PG = PageParams(1, 20) + + +class _StubQuotation: + """regenerate_quotation 을 대신한다 — 인자를 기록하고 정해진 결과만 돌려준다. + ok=False 면 재생성 실패를 흉내낸다(승인이 상태를 넘기면 안 되는 경로 검증).""" + + def __init__(self, *, ok=True, qt_id=None): + self.ok = ok + self.qt_id = qt_id or uuid.uuid4() + self.calls = [] + + async def regenerate_quotation(self, qt_id, company_id, supplier_ids, user_id=None, role=None, regen_label=None): + self.calls.append( + {"qt_id": qt_id, "company_id": company_id, "supplier_ids": list(supplier_ids), + "user_id": user_id, "role": role, "regen_label": regen_label} + ) + res = Res_CreateQuotation() + if self.ok: + res.qt_id = self.qt_id + else: + res.result.SetResult(ErrorType.FAIL) + res.msg = "stub 재생성 실패" + return res + + +# ===== 목록 ===== +async def test_list_scoped_to_company(db_engine, company_id, other_company_id): + """검증: 내 회사 대기요청 1건 + 남의 회사 대기요청 1건이 있을 때 내 회사로 목록 조회. + 기대결과: 내 회사 건만(total=1) 나오고, 남의 회사 세션 id 는 결과에 없다.""" + mine = await _seed_request(db_engine, company_id, number="R-MINE", renego=_pending()) + await _seed_request(db_engine, other_company_id, number="R-THEIRS", renego=_pending()) + + res = await _service().list_requests(company_id, str(uuid.uuid4()), UserRole.OWNER.value, None, PG) + + assert res.result.success is True + assert res.total == 1 + assert [r.session_id for r in res.requests] == [mine["session_id"]] + + +async def test_list_filters_by_status(db_engine, company_id): + """검증: 같은 회사에 대기(PENDING)·반려(REJECTED) 요청을 하나씩 두고 status=1(대기)로 필터. + 기대결과: 대기 건만 반환(total=1).""" + pending = await _seed_request(db_engine, company_id, number="R-P", renego=_pending()) + await _seed_request(db_engine, company_id, number="R-R", renego=_decided(RenegotiationStatus.REJECTED.value)) + + res = await _service().list_requests(company_id, str(uuid.uuid4()), UserRole.OWNER.value, RenegotiationStatus.PENDING.value, PG) + + assert res.total == 1 + assert res.requests[0].session_id == pending["session_id"] + assert res.requests[0].status == RenegotiationStatus.PENDING.value + + +async def test_list_shows_decider_name(db_engine, company_id): + """검증: 처리(승인/반려)된 요청의 decided_by(담당자 user_id)로 담당자 이름을 조인해 내려준다. + 기대결과: 목록 항목의 decided_by_name 이 그 담당자 이름.""" + decider_id = uuid.uuid4() + await _seed_user(db_engine, company_id, decider_id, "김담당") + renego = {**_decided(RenegotiationStatus.APPROVED.value), "decided_by": str(decider_id)} + await _seed_request(db_engine, company_id, number="R-WHO", renego=renego) + + res = await _service().list_requests(company_id, str(uuid.uuid4()), UserRole.OWNER.value, None, PG) + + assert res.requests[0].decided_by_name == "김담당" + + +async def test_list_shows_all_with_can_act_flags(db_engine, company_id): + """검증: 같은 회사에 서로 다른 작성자의 요청 2건. 리스트엔 전체가 보이되, 처리 권한은 can_act 로 온다. + 기대결과: 일반관리자(A)는 둘 다 보이고(total=2) can_act 는 자기(A) 것만 True. OWNER 는 전부 True.""" + a = await _seed_request(db_engine, company_id, number="R-A", renego=_pending()) + b = await _seed_request(db_engine, company_id, number="R-B", renego=_pending()) + + res = await _service().list_requests(company_id, a["user_id"], UserRole.USER.value, None, PG) + assert res.total == 2 + can = {r.session_id: r.can_act for r in res.requests} + assert can[a["session_id"]] is True + assert can[b["session_id"]] is False + + owner = await _service().list_requests(company_id, a["user_id"], UserRole.OWNER.value, None, PG) + assert all(r.can_act for r in owner.requests) + + +# ===== 승인 ===== +async def test_approve_transitions_and_persists(db_engine, company_id): + """검증: 대기 요청을 승인(재생성 성공 스텁, 승인메모 첨부). + 기대결과: APPROVED + next_quotation_id 저장 + memo 저장, 재생성엔 요청 공급사가 포함돼 호출된다.""" + seed = await _seed_request(db_engine, company_id, number="R-OK", renego=_pending()) + stub = _StubQuotation(ok=True) + svc = _service(stub) + + req = _approve_req(memo="조건 재검토 승인") + res = await svc.approve(company_id, seed["user_id"], UserRole.USER.value, seed["session_id"], req) + + assert res.result.success is True + assert res.status == RenegotiationStatus.APPROVED.value + assert res.next_quotation_id == str(stub.qt_id) + # 재생성은 정확히 1번, 요청 공급사를 포함해서 호출 + assert len(stub.calls) == 1 + assert seed["supplier_id"] in stub.calls[0]["supplier_ids"] + assert stub.calls[0]["qt_id"] == seed["quotation_id"] + # 재생성 견적 타이틀 마킹용 라벨을 넘긴다(수동 재생성과 구분). + assert stub.calls[0]["regen_label"] == "재협상 요청 재생성" + + saved = await _renego_of(db_engine, seed["session_id"]) + assert saved["status"] == RenegotiationStatus.APPROVED.value + assert saved["next_quotation_id"] == str(stub.qt_id) + assert saved["memo"] == "조건 재검토 승인" + + +async def test_approve_includes_extra_suppliers(db_engine, company_id): + """검증: 승인 시 요청자 외 추가 공급사(supplier_ids)를 함께 지정. + 기대결과: 재생성 호출의 공급사 집합에 요청자 + 추가 공급사가 모두 들어간다(중복 없이).""" + seed = await _seed_request(db_engine, company_id, number="R-MULTI", renego=_pending()) + extra = str(uuid.uuid4()) + stub = _StubQuotation(ok=True) + + req = _approve_req(supplier_ids=[extra, seed["supplier_id"]]) # 요청자 중복 포함 + await _service(stub).approve(company_id, seed["user_id"], UserRole.USER.value, seed["session_id"], req) + + got = set(stub.calls[0]["supplier_ids"]) + assert got == {seed["supplier_id"], extra} + + +async def test_approve_blocks_when_not_pending(db_engine, company_id): + """검증: 이미 승인된(APPROVED) 요청에 승인 재시도(멱등 가드). + 기대결과: 거부(INVALID_REQUEST_DATA) + 재생성 미호출.""" + seed = await _seed_request( + db_engine, company_id, number="R-DONE", renego=_decided(RenegotiationStatus.APPROVED.value) + ) + stub = _StubQuotation(ok=True) + + res = await _service(stub).approve(company_id, str(uuid.uuid4()), UserRole.USER.value, seed["session_id"], _approve_req()) + + assert res.result.success is False + assert res.result.code == ErrorType.INVALID_REQUEST_DATA.value + assert stub.calls == [] + + +async def test_approve_keeps_pending_when_regenerate_fails(db_engine, company_id): + """검증: 대기 요청 승인 중 재생성이 실패(스텁 ok=False). + 기대결과: 실패 반환 + 상태는 PENDING 그대로(성급히 APPROVED 로 넘기지 않음).""" + seed = await _seed_request(db_engine, company_id, number="R-FAIL", renego=_pending()) + stub = _StubQuotation(ok=False) + + res = await _service(stub).approve(company_id, seed["user_id"], UserRole.USER.value, seed["session_id"], _approve_req()) + + assert res.result.success is False + saved = await _renego_of(db_engine, seed["session_id"]) + assert saved["status"] == RenegotiationStatus.PENDING.value + assert "next_quotation_id" not in saved or saved["next_quotation_id"] is None + + +async def test_approve_other_company_not_found(db_engine, company_id, other_company_id): + """검증: 남의 회사 요청 세션을 내 회사 자격으로 승인 시도(IDOR). + 기대결과: NOT_FOUND(회사 스코프 밖) + 재생성 미호출.""" + seed = await _seed_request(db_engine, other_company_id, number="R-IDOR", renego=_pending()) + stub = _StubQuotation(ok=True) + + res = await _service(stub).approve(company_id, str(uuid.uuid4()), UserRole.USER.value, seed["session_id"], _approve_req()) + + assert res.result.success is False + assert res.result.code == ErrorType.QUOTATION_NOT_FOUND.value + assert stub.calls == [] + + +# ===== 반려 ===== +async def test_reject_transitions_and_saves_memo(db_engine, company_id): + """검증: 대기 요청을 사유와 함께 반려. + 기대결과: REJECTED + memo(반려 사유) 저장.""" + seed = await _seed_request(db_engine, company_id, number="R-REJ", renego=_pending()) + + res = await _service().reject(company_id, seed["user_id"], UserRole.USER.value, seed["session_id"], _reject_req("단종 품목이라 불가")) + + assert res.result.success is True + assert res.status == RenegotiationStatus.REJECTED.value + saved = await _renego_of(db_engine, seed["session_id"]) + assert saved["status"] == RenegotiationStatus.REJECTED.value + assert saved["memo"] == "단종 품목이라 불가" + + +async def test_reject_blocks_when_not_pending(db_engine, company_id): + """검증: 이미 반려된 요청에 반려 재시도. + 기대결과: 거부(INVALID_REQUEST_DATA).""" + seed = await _seed_request( + db_engine, company_id, number="R-REJ2", renego=_decided(RenegotiationStatus.REJECTED.value) + ) + + res = await _service().reject(company_id, seed["user_id"], UserRole.USER.value, seed["session_id"], _reject_req("x")) + + assert res.result.success is False + assert res.result.code == ErrorType.INVALID_REQUEST_DATA.value + + +# ===== 소유권 게이팅 ===== +async def test_approve_forbidden_for_non_owner_user(db_engine, company_id): + """검증: 남의 견적 재협상 요청을 일반관리자(비소유·USER)가 승인 시도. + 기대결과: 거부(ACCOUNT_FORBIDDEN) + 재생성 미호출.""" + seed = await _seed_request(db_engine, company_id, number="R-NOTMINE", renego=_pending()) + stub = _StubQuotation(ok=True) + + res = await _service(stub).approve(company_id, str(uuid.uuid4()), UserRole.USER.value, seed["session_id"], _approve_req()) + + assert res.result.success is False + assert res.result.code == ErrorType.ACCOUNT_FORBIDDEN.value + assert stub.calls == [] + + +async def test_approve_allowed_for_owner(db_engine, company_id): + """검증: 남의 견적이라도 최고관리자(OWNER)면 승인. + 기대결과: 성공 + 재생성 호출.""" + seed = await _seed_request(db_engine, company_id, number="R-OWNER", renego=_pending()) + stub = _StubQuotation(ok=True) + + res = await _service(stub).approve(company_id, str(uuid.uuid4()), UserRole.OWNER.value, seed["session_id"], _approve_req()) + + assert res.result.success is True + assert len(stub.calls) == 1 + + +async def test_reject_forbidden_for_non_owner_user(db_engine, company_id): + """검증: 남의 견적 재협상 요청을 일반관리자가 반려 시도. + 기대결과: 거부(ACCOUNT_FORBIDDEN).""" + seed = await _seed_request(db_engine, company_id, number="R-REJNOT", renego=_pending()) + + res = await _service().reject(company_id, str(uuid.uuid4()), UserRole.USER.value, seed["session_id"], _reject_req("x")) + + assert res.result.success is False + assert res.result.code == ErrorType.ACCOUNT_FORBIDDEN.value + + +# ===== 헬퍼 ===== +def _pending(): + return { + "status": RenegotiationStatus.PENDING.value, + "reason": "가격 재검토 요청", + "desired_price": 90000, + "requested_at": "2026-07-20T00:00:00+00:00", + } + + +def _decided(status): + return {**_pending(), "status": status, "decided_at": "2026-07-21T00:00:00+00:00", "memo": "기존 판단"} + + +def _approve_req(*, supplier_ids=None, memo=""): + from router.v1.renegotiation.protocol import Req_ApproveRenegotiation + + return Req_ApproveRenegotiation(supplier_ids=supplier_ids or [], memo=memo) + + +def _reject_req(memo): + from router.v1.renegotiation.protocol import Req_RejectRenegotiation + + return Req_RejectRenegotiation(memo=memo) + + +def _service(quotation_stub=None): + return RenegotiationService(RenegotiationCRUD(), quotation_stub or _StubQuotation()) + + +async def _seed_user(engine, company_id, user_id, name): + """담당자 유저 1건 시드(decided_by 이름 조인 확인용).""" + async with engine.begin() as conn: + await conn.execute( + text( + "INSERT INTO users (user_id, company_id, id, password, name, status, role, last_accessed_at) " + "VALUES (:uid, :cid, :login, 'x', :name, 1, :role, now())" + ), + {"uid": user_id, "cid": uuid.UUID(company_id), "login": f"dec-{str(user_id)[:8]}", "name": name, "role": UserRole.USER.value}, + ) + + +async def _seed_request(engine, company_id, *, number, renego, close_reason=CloseReason.OPEN_PRICE.value, round_=1): + """재협상 요청 1건 시드: 작성자(회사 스코프) + 마감견적 + custom.renegotiation 달린 세션. + 목록 쿼리가 quotations→users(company_id)·items·suppliers 를 조인하므로 이들을 함께 넣는다.""" + user_id, qt_id, session_id = uuid.uuid4(), uuid.uuid4(), uuid.uuid4() + supplier_id, item_id = uuid.uuid4(), uuid.uuid4() + custom = {"renegotiation": renego} + async with engine.begin() as conn: + await conn.execute( + text( + "INSERT INTO users (user_id, company_id, id, password, name, status, role, last_accessed_at) " + "VALUES (:uid, :cid, :login, 'x', '담당', 1, :role, now())" + ), + {"uid": user_id, "cid": uuid.UUID(company_id), "login": f"u-{number}", "role": UserRole.USER.value}, + ) + 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, :uid, :setting, :version, '견적', :number, :type, :status, :close_reason, " + " :round, 0, :past, :past, false)" + ), + { + "qt_id": qt_id, "uid": user_id, "setting": uuid.uuid4(), "version": uuid.uuid4(), + "number": number, "type": QuotationType.REQUOTE.value, "status": QuotationStatus.CLOSED.value, + "close_reason": close_reason, "round": round_, "past": PAST, + }, + ) + # items·suppliers 는 목록 쿼리에서 outerjoin 이라 시드 없이도 된다(이름은 빈 문자열로 채워짐). + 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, custom) VALUES " + "(:sid, :qt_id, :iid, :spid, :number, :round, :type, " + " 100000, :sstatus, 95000, :past, CAST(:custom AS JSONB))" + ), + { + "sid": session_id, "qt_id": qt_id, "iid": item_id, "spid": supplier_id, + "number": number, "round": round_, "type": QuotationType.REQUOTE.value, + "sstatus": SessionStatus.DONE.value, "past": PAST, "custom": json.dumps(custom), + }, + ) + return { + "session_id": str(session_id), "quotation_id": str(qt_id), + "supplier_id": str(supplier_id), "user_id": str(user_id), + } + + +async def _renego_of(engine, session_id): + """세션 custom.renegotiation 을 읽어 dict 로 (저장 결과 확인용).""" + async with engine.begin() as conn: + row = (await conn.execute( + text("SELECT custom FROM sessions WHERE session_id = :sid"), + {"sid": uuid.UUID(session_id)}, + )).one() + return (row[0] or {}).get("renegotiation") or {} diff --git a/negodata/backend/tests/test_scheduler.py b/negodata/backend/tests/test_scheduler.py index 0abc014..4249320 100644 --- a/negodata/backend/tests/test_scheduler.py +++ b/negodata/backend/tests/test_scheduler.py @@ -123,14 +123,14 @@ async def test_scheduler_disabled_without_env(monkeypatch): async def test_scheduler_registers_both_jobs(monkeypatch): """검증: SCHEDULER_ENABLED=1 로 start_scheduler() 호출. - 기대결과: 마감 잡 2개(close_expired·close_negotiated)가 스케줄에 등록된다.""" + 기대결과: 마감 잡 2개(close_expired·close_negotiated) + LPS 수집 잡이 스케줄에 등록된다.""" import scheduler monkeypatch.setenv("SCHEDULER_ENABLED", "1") scheduler._scheduler = None scheduler.start_scheduler() try: ids = {j.id for j in scheduler._scheduler.get_jobs()} - assert ids == {"close_expired_quotations", "close_negotiated_quotations"} + assert ids == {"close_expired_quotations", "close_negotiated_quotations", "sync_lps_results"} finally: scheduler.shutdown_scheduler() assert scheduler._scheduler is None diff --git a/negodata/docs/imk-test-coverage.md b/negodata/docs/imk-test-coverage.md new file mode 100644 index 0000000..c181b84 --- /dev/null +++ b/negodata/docs/imk-test-coverage.md @@ -0,0 +1,68 @@ +# IMK 요구사항 테스트 커버리지 (2026-07-24 실행) + +IMK 요청 표(14~23) 항목별 **자동 테스트가 실제로 걸려 있는지**와, 이번에 돌린 실행 플로우를 정리한다. +결론부터: **자동 테스트가 있는 IMK 항목은 #15(공급사 재협상) 하나뿐**. 나머지는 UI·라벨·엑셀·스크립트 변경이라 전 스위트(회귀 안전망)만 통과할 뿐 항목 전용 테스트는 없다. + +## 1. 실행한 테스트 스위트 + +| 스위트 | 실행 커맨드 | DB | 결과 | +|---|---|---|---| +| negodata 전체 | `APP_ENV=test .venv/bin/python -m pytest tests/` | 격리 test DB(negosium_test_db, 세션마다 재생성) | **83 passed / 1 failed** | +| 포털(negosium) 전체 | 컨테이너 `APP_ENV=local pytest tests/` | dev DB(negosium_db), 테스트가 자기 행만 시드/정리(비파괴) | **71 passed** | +| negodata #15 심사 | `pytest tests/test_renegotiation.py` | 격리 test DB | **9 passed** | +| 포털 #15 요청/철회 | 컨테이너 `pytest tests/test_renegotiation.py` | dev DB(자기정리, 잔여 0 확인) | **6 passed** | + +- negodata 1 실패 = `test_scheduler::test_scheduler_registers_both_jobs` — 스케줄러에 `sync_lps_results` 잡이 새로 추가됐는데 단언을 안 고친 **stale 테스트**. IMK 항목과 무관. +- 포털은 로컬 venv가 없어 컨테이너에 pytest 임시 설치 후 실행. dev DB지만 `PYTESTRENEGO-`/`PYTESTNEGO-` 프리픽스로 자기 행만 지운다. + +## 2. IMK 표(14~23) 항목별 커버리지 + +| # | 요구사항 | 자동 테스트 | 근거/플로우 | +|---|---|---|---| +| 14 | 발주배수 필드 추가 | ❌ 없음 | companies.settings 커스텀 필드. 전용 테스트 없음 | +| **15** | **공급사 재협상 요청 + 승인 화면** | ✅ **있음(15건)** | 포털 요청/철회 6 + negodata 심사 9. 아래 3절 | +| 16 | 통계 인상 억제율 지표 | ❌ 없음 | 통계 파생집계, 전용 테스트 없음 | +| 17 | 협력사 분류카테고리 추가 | ❌ 없음(간접만) | `test_quotation_create/anchoring`이 category 값을 쓰지만 분류 기능 자체 검증 아님 | +| 18 | 배송리드타임 → 표준납기일 | ❌ 없음 | 라벨 문자열 변경, 테스트 대상 아님 | +| 19 | 협력사 화면 구분 영역 삭제 | ❌ 없음 | 프론트 UI 제거, 테스트 대상 아님 | +| 20 | 실적(계약) 공급사 컬럼 추가 | ❌ 없음 | 상품 컬럼 추가, 전용 테스트 없음 | +| 21 | 스크립트 변수명 노출(internet_lowest_price) | ❌ 없음 | agent 스크립트 렌더 수정, 전용 테스트 없음 | +| 22 | 협상카드 일괄 선택 | ❌ 없음 | 프론트 UI, 테스트 대상 아님 | +| 23 | 상품 업로드 양식 수정 | ❌ 없음 | 엑셀 양식/컬럼, 전용 테스트 없음 | + +정리: **10개 중 자동 테스트 보유는 1개(#15)**. 나머지 9개는 성격상(UI/라벨/엑셀/스크립트) 단위테스트 대상이 아니거나 아직 미작성 → 사람이 화면에서 확인해야 함. + +## 3. #15 재협상 — 테스트별 검증 플로우 + +### 포털(negosium) — 요청/철회 (`backend/tests/test_renegotiation.py`, e2e HTTP) +개찰(OPEN_*) 마감 + 본인 마지막 라운드 세션을 시드하고 실제 로그인 → 엔드포인트 호출로 검증. + +| 테스트 | 플로우 | 기대 | +|---|---|---| +| request_records_pending | 개찰건에 `POST .../renegotiation` | success + custom.renegotiation=PENDING(사유·희망가) + 담당자 알림 1건 | +| request_twice_blocked | 같은 세션에 요청 2회 | 2번째 거부, PENDING 1건 유지 | +| request_blocked_on_awarded | 낙찰(AWARDED)건에 요청 | 거부 + 미기록 | +| request_forbidden_other_supplier | 남의 공급사 세션에 요청 | 거부 + 미기록 | +| cancel_sets_canceled_and_allows_rerequest | 요청 후 `DELETE` 철회 → 재요청 | CANCELED → 재요청 시 PENDING | +| cancel_requires_pending | 대기 요청 없는데 철회 | 거부 | + +### negodata — 심사(승인/반려) (`negodata/backend/tests/test_renegotiation.py`, 서비스 단위) +승인이 호출하는 `regenerate_quotation`(견적 풀체인)은 스텁으로 격리하고 #15 고유 계약만 검증. + +| 테스트 | 플로우 | 기대 | +|---|---|---| +| list_scoped_to_company | 내 회사·남의 회사 요청 각 1건 → 목록 | 내 회사 건만(total=1) | +| list_filters_by_status | 대기·반려 각 1건 → status=1 필터 | 대기 건만 | +| approve_transitions_and_persists | 대기건 승인(재생성 성공 스텁) | APPROVED + next_quotation_id + memo 박제, 요청 공급사 포함 호출 | +| approve_includes_extra_suppliers | 승인 시 추가 공급사 지정 | 재생성 공급사 = 요청자 ∪ 추가(중복 제거) | +| approve_blocks_when_not_pending | 이미 승인된 건 재승인 | 거부(INVALID) + 재생성 미호출 | +| approve_keeps_pending_when_regenerate_fails | 재생성 실패 스텁 | 실패 반환 + 상태 PENDING 유지 | +| approve_other_company_not_found | 남의 회사 세션 승인(IDOR) | NOT_FOUND + 재생성 미호출 | +| reject_transitions_and_saves_memo | 대기건 반려(사유) | REJECTED + 반려사유 저장 | +| reject_blocks_when_not_pending | 이미 반려된 건 재반려 | 거부(INVALID) | + +## 4. 미커버 항목에 대한 권고 + +- #14/#16/#17/#20/#23(데이터·집계·엑셀)은 서비스 단위 테스트를 붙일 수 있음 — 필요 시 작성. +- #18/#19/#22(라벨·UI)와 #21(스크립트 렌더)은 화면·실협상으로 확인하는 게 맞음. +- #15는 "승인 → 실제 다음 라운드 견적이 올바른 상품/카드로 생성되는지"는 스텁으로 끊었으므로, 실 데이터 승인 1회로 최종 확인 필요. diff --git a/negodata/front/src/api/generated/model/index.ts b/negodata/front/src/api/generated/model/index.ts index ce5ed74..7d175ad 100644 --- a/negodata/front/src/api/generated/model/index.ts +++ b/negodata/front/src/api/generated/model/index.ts @@ -80,6 +80,7 @@ export * from './listCardsParams'; export * from './listItemsParams'; export * from './listNotificationsParams'; export * from './listQuotationsParams'; +export * from './listRequestsParams'; export * from './listSuppliersParams'; export * from './listUsersParams'; export * from './lowestPriceEntry'; @@ -129,6 +130,16 @@ export * from './quotationSettingDataUpdatedAt'; export * from './quotationSettingDataUserId'; export * from './quotationStatus'; export * from './quotationType'; +export * from './renegotiationData'; +export * from './renegotiationDataBidPrice'; +export * from './renegotiationDataCloseReason'; +export * from './renegotiationDataDecidedAt'; +export * from './renegotiationDataDecidedByName'; +export * from './renegotiationDataDesiredPrice'; +export * from './renegotiationDataMemo'; +export * from './renegotiationDataNextQuotationId'; +export * from './renegotiationDataTargetPrice'; +export * from './reqApproveRenegotiation'; export * from './reqAwardQuotation'; export * from './reqBulkMapByNames'; export * from './reqCheckCodes'; @@ -183,6 +194,7 @@ export * from './reqCreateSupplierManagerName'; export * from './reqCreateSupplierTotalRevenue'; export * from './reqLogin'; export * from './reqRegenerateQuotation'; +export * from './reqRejectRenegotiation'; export * from './reqResetSupplierAccountPassword'; export * from './reqResetSupplierAccountPasswordPassword'; export * from './reqUpdateCard'; @@ -344,6 +356,11 @@ export * from './resQuotationStatusMsg'; export * from './resQuotationStatusQtId'; export * from './resRefreshToken'; export * from './resRefreshTokenMsg'; +export * from './resRenegotiationDecision'; +export * from './resRenegotiationDecisionMsg'; +export * from './resRenegotiationDecisionNextQuotationId'; +export * from './resRenegotiationList'; +export * from './resRenegotiationListMsg'; export * from './resResetSupplierAccountPassword'; export * from './resResetSupplierAccountPasswordMsg'; export * from './resResetSupplierAccountPasswordNewPassword'; diff --git a/negodata/front/src/api/generated/model/listRequestsParams.ts b/negodata/front/src/api/generated/model/listRequestsParams.ts new file mode 100644 index 0000000..8f44cf4 --- /dev/null +++ b/negodata/front/src/api/generated/model/listRequestsParams.ts @@ -0,0 +1,22 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ListRequestsParams = { +/** + * 요청 상태(1=심사중 2=승인 3=반려 4=철회). 미지정 시 전체 + */ +status?: number | null; +/** + * @minimum 1 + */ +page?: number; +/** + * @minimum 1 + * @maximum 100 + */ +size?: number; +}; diff --git a/negodata/front/src/api/generated/model/notificationType.ts b/negodata/front/src/api/generated/model/notificationType.ts index 6f76b94..0855ac7 100644 --- a/negodata/front/src/api/generated/model/notificationType.ts +++ b/negodata/front/src/api/generated/model/notificationType.ts @@ -17,4 +17,5 @@ export const NotificationType = { REGENERATED: 2, FAILURE: 3, CREATED: 4, + RENEGO_REQUESTED: 5, } as const; diff --git a/negodata/front/src/api/generated/model/renegotiationData.ts b/negodata/front/src/api/generated/model/renegotiationData.ts new file mode 100644 index 0000000..6c3be45 --- /dev/null +++ b/negodata/front/src/api/generated/model/renegotiationData.ts @@ -0,0 +1,39 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { RenegotiationDataDesiredPrice } from './renegotiationDataDesiredPrice'; +import type { RenegotiationDataDecidedAt } from './renegotiationDataDecidedAt'; +import type { RenegotiationDataDecidedByName } from './renegotiationDataDecidedByName'; +import type { RenegotiationDataMemo } from './renegotiationDataMemo'; +import type { RenegotiationDataNextQuotationId } from './renegotiationDataNextQuotationId'; +import type { RenegotiationDataCloseReason } from './renegotiationDataCloseReason'; +import type { RenegotiationDataTargetPrice } from './renegotiationDataTargetPrice'; +import type { RenegotiationDataBidPrice } from './renegotiationDataBidPrice'; + +export interface RenegotiationData { + session_id?: string; + quotation_id?: string; + qt_number?: string; + qt_round?: number; + qt_name?: string; + item_id?: string; + item_name?: string; + supplier_id?: string; + supplier_name?: string; + owner_name?: string; + status?: number; + reason?: string; + desired_price?: RenegotiationDataDesiredPrice; + requested_at?: string; + decided_at?: RenegotiationDataDecidedAt; + decided_by_name?: RenegotiationDataDecidedByName; + memo?: RenegotiationDataMemo; + next_quotation_id?: RenegotiationDataNextQuotationId; + close_reason?: RenegotiationDataCloseReason; + target_price?: RenegotiationDataTargetPrice; + bid_price?: RenegotiationDataBidPrice; + can_act?: boolean; +} diff --git a/negodata/front/src/api/generated/model/renegotiationDataBidPrice.ts b/negodata/front/src/api/generated/model/renegotiationDataBidPrice.ts new file mode 100644 index 0000000..b60245e --- /dev/null +++ b/negodata/front/src/api/generated/model/renegotiationDataBidPrice.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 RenegotiationDataBidPrice = number | null; diff --git a/negodata/front/src/api/generated/model/renegotiationDataCloseReason.ts b/negodata/front/src/api/generated/model/renegotiationDataCloseReason.ts new file mode 100644 index 0000000..4161931 --- /dev/null +++ b/negodata/front/src/api/generated/model/renegotiationDataCloseReason.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 RenegotiationDataCloseReason = number | null; diff --git a/negodata/front/src/api/generated/model/renegotiationDataDecidedAt.ts b/negodata/front/src/api/generated/model/renegotiationDataDecidedAt.ts new file mode 100644 index 0000000..979991d --- /dev/null +++ b/negodata/front/src/api/generated/model/renegotiationDataDecidedAt.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 RenegotiationDataDecidedAt = string | null; diff --git a/negodata/front/src/api/generated/model/renegotiationDataDecidedByName.ts b/negodata/front/src/api/generated/model/renegotiationDataDecidedByName.ts new file mode 100644 index 0000000..705f932 --- /dev/null +++ b/negodata/front/src/api/generated/model/renegotiationDataDecidedByName.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 RenegotiationDataDecidedByName = string | null; diff --git a/negodata/front/src/api/generated/model/renegotiationDataDesiredPrice.ts b/negodata/front/src/api/generated/model/renegotiationDataDesiredPrice.ts new file mode 100644 index 0000000..f3c2011 --- /dev/null +++ b/negodata/front/src/api/generated/model/renegotiationDataDesiredPrice.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 RenegotiationDataDesiredPrice = number | null; diff --git a/negodata/front/src/api/generated/model/renegotiationDataMemo.ts b/negodata/front/src/api/generated/model/renegotiationDataMemo.ts new file mode 100644 index 0000000..e7d57e6 --- /dev/null +++ b/negodata/front/src/api/generated/model/renegotiationDataMemo.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 RenegotiationDataMemo = string | null; diff --git a/negodata/front/src/api/generated/model/renegotiationDataNextQuotationId.ts b/negodata/front/src/api/generated/model/renegotiationDataNextQuotationId.ts new file mode 100644 index 0000000..ffcaeb3 --- /dev/null +++ b/negodata/front/src/api/generated/model/renegotiationDataNextQuotationId.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 RenegotiationDataNextQuotationId = string | null; diff --git a/negodata/front/src/api/generated/model/renegotiationDataTargetPrice.ts b/negodata/front/src/api/generated/model/renegotiationDataTargetPrice.ts new file mode 100644 index 0000000..5ab5209 --- /dev/null +++ b/negodata/front/src/api/generated/model/renegotiationDataTargetPrice.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 RenegotiationDataTargetPrice = number | null; diff --git a/negodata/front/src/api/generated/model/reqApproveRenegotiation.ts b/negodata/front/src/api/generated/model/reqApproveRenegotiation.ts new file mode 100644 index 0000000..531c564 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqApproveRenegotiation.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export interface ReqApproveRenegotiation { + /** 다음 라운드에 함께 넣을 공급사. 비우면 요청자만 */ + supplier_ids?: string[]; + /** + * 승인 메모 + * @maxLength 255 + */ + memo?: string; +} diff --git a/negodata/front/src/api/generated/model/reqRejectRenegotiation.ts b/negodata/front/src/api/generated/model/reqRejectRenegotiation.ts new file mode 100644 index 0000000..9949404 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqRejectRenegotiation.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export interface ReqRejectRenegotiation { + /** + * 반려 사유 — 공급사에게 그대로 노출된다 + * @maxLength 255 + */ + memo?: string; +} diff --git a/negodata/front/src/api/generated/model/resRenegotiationDecision.ts b/negodata/front/src/api/generated/model/resRenegotiationDecision.ts new file mode 100644 index 0000000..f381bf3 --- /dev/null +++ b/negodata/front/src/api/generated/model/resRenegotiationDecision.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { ErrorInfo } from './errorInfo'; +import type { ResRenegotiationDecisionMsg } from './resRenegotiationDecisionMsg'; +import type { ResRenegotiationDecisionNextQuotationId } from './resRenegotiationDecisionNextQuotationId'; + +export interface ResRenegotiationDecision { + result?: ErrorInfo; + msg?: ResRenegotiationDecisionMsg; + session_id?: string; + status?: number; + next_quotation_id?: ResRenegotiationDecisionNextQuotationId; +} diff --git a/negodata/front/src/api/generated/model/resRenegotiationDecisionMsg.ts b/negodata/front/src/api/generated/model/resRenegotiationDecisionMsg.ts new file mode 100644 index 0000000..714978a --- /dev/null +++ b/negodata/front/src/api/generated/model/resRenegotiationDecisionMsg.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 ResRenegotiationDecisionMsg = string | null; diff --git a/negodata/front/src/api/generated/model/resRenegotiationDecisionNextQuotationId.ts b/negodata/front/src/api/generated/model/resRenegotiationDecisionNextQuotationId.ts new file mode 100644 index 0000000..4eb5e6b --- /dev/null +++ b/negodata/front/src/api/generated/model/resRenegotiationDecisionNextQuotationId.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 ResRenegotiationDecisionNextQuotationId = string | null; diff --git a/negodata/front/src/api/generated/model/resRenegotiationList.ts b/negodata/front/src/api/generated/model/resRenegotiationList.ts new file mode 100644 index 0000000..25151b8 --- /dev/null +++ b/negodata/front/src/api/generated/model/resRenegotiationList.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { ErrorInfo } from './errorInfo'; +import type { ResRenegotiationListMsg } from './resRenegotiationListMsg'; +import type { RenegotiationData } from './renegotiationData'; + +export interface ResRenegotiationList { + result?: ErrorInfo; + msg?: ResRenegotiationListMsg; + requests?: RenegotiationData[]; + total?: number; + page?: number; + size?: number; +} diff --git a/negodata/front/src/api/generated/model/resRenegotiationListMsg.ts b/negodata/front/src/api/generated/model/resRenegotiationListMsg.ts new file mode 100644 index 0000000..9ab43e7 --- /dev/null +++ b/negodata/front/src/api/generated/model/resRenegotiationListMsg.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 ResRenegotiationListMsg = string | null; diff --git a/negodata/front/src/api/generated/renegotiation/renegotiation.ts b/negodata/front/src/api/generated/renegotiation/renegotiation.ts new file mode 100644 index 0000000..0e7ff4f --- /dev/null +++ b/negodata/front/src/api/generated/renegotiation/renegotiation.ts @@ -0,0 +1,265 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import { + useMutation, + useQuery +} from '@tanstack/react-query'; +import type { + DataTag, + DefinedInitialDataOptions, + DefinedUseQueryResult, + MutationFunction, + QueryClient, + QueryFunction, + QueryKey, + UndefinedInitialDataOptions, + UseMutationOptions, + UseMutationResult, + UseQueryOptions, + UseQueryResult +} from '@tanstack/react-query'; + +import type { + HTTPValidationError, + ListRequestsParams, + ReqApproveRenegotiation, + ReqRejectRenegotiation, + ResRenegotiationDecision, + ResRenegotiationList +} from '.././model'; + +import { customFetch } from '../../mutator/custom-fetch'; + + +type SecondParameter unknown> = Parameters[1]; + + + +/** + * @summary 재협상 요청 현황 + */ +export const listRequests = ( + params?: ListRequestsParams, + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/renegotiation/list`, method: 'GET', + params, signal + }, + options); + } + + + + +export const getListRequestsQueryKey = (params?: ListRequestsParams,) => { + return [ + `/v1/renegotiation/list`, ...(params ? [params]: []) + ] as const; + } + + +export const getListRequestsQueryOptions = >, TError = void | HTTPValidationError>(params?: ListRequestsParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getListRequestsQueryKey(params); + + + + const queryFn: QueryFunction>> = ({ signal }) => listRequests(params, requestOptions, signal); + + + + + + return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type ListRequestsQueryResult = NonNullable>> +export type ListRequestsQueryError = void | HTTPValidationError + + +export function useListRequests>, TError = void | HTTPValidationError>( + params: undefined | ListRequestsParams, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useListRequests>, TError = void | HTTPValidationError>( + params?: ListRequestsParams, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useListRequests>, TError = void | HTTPValidationError>( + params?: ListRequestsParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary 재협상 요청 현황 + */ + +export function useListRequests>, TError = void | HTTPValidationError>( + params?: ListRequestsParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getListRequestsQueryOptions(params,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey ; + + return query; +} + + + + +/** + * @summary 재협상 승인 + */ +export const approve = ( + sessionId: string, + reqApproveRenegotiation: ReqApproveRenegotiation, + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/renegotiation/${sessionId}/approve`, method: 'POST', + headers: {'Content-Type': 'application/json', }, + data: reqApproveRenegotiation, signal + }, + options); + } + + + +export const getApproveMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{sessionId: string;data: ReqApproveRenegotiation}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{sessionId: string;data: ReqApproveRenegotiation}, TContext> => { + +const mutationKey = ['approve']; +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>, {sessionId: string;data: ReqApproveRenegotiation}> = (props) => { + const {sessionId,data} = props ?? {}; + + return approve(sessionId,data,requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type ApproveMutationResult = NonNullable>> + export type ApproveMutationBody = ReqApproveRenegotiation + export type ApproveMutationError = void | HTTPValidationError + + /** + * @summary 재협상 승인 + */ +export const useApprove = (options?: { mutation?:UseMutationOptions>, TError,{sessionId: string;data: ReqApproveRenegotiation}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {sessionId: string;data: ReqApproveRenegotiation}, + TContext + > => { + + const mutationOptions = getApproveMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } + /** + * @summary 재협상 반려 + */ +export const reject = ( + sessionId: string, + reqRejectRenegotiation: ReqRejectRenegotiation, + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/renegotiation/${sessionId}/reject`, method: 'POST', + headers: {'Content-Type': 'application/json', }, + data: reqRejectRenegotiation, signal + }, + options); + } + + + +export const getRejectMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{sessionId: string;data: ReqRejectRenegotiation}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{sessionId: string;data: ReqRejectRenegotiation}, TContext> => { + +const mutationKey = ['reject']; +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>, {sessionId: string;data: ReqRejectRenegotiation}> = (props) => { + const {sessionId,data} = props ?? {}; + + return reject(sessionId,data,requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type RejectMutationResult = NonNullable>> + export type RejectMutationBody = ReqRejectRenegotiation + export type RejectMutationError = void | HTTPValidationError + + /** + * @summary 재협상 반려 + */ +export const useReject = (options?: { mutation?:UseMutationOptions>, TError,{sessionId: string;data: ReqRejectRenegotiation}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {sessionId: string;data: ReqRejectRenegotiation}, + TContext + > => { + + const mutationOptions = getRejectMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } + \ No newline at end of file diff --git a/negodata/front/src/app/provider.tsx b/negodata/front/src/app/provider.tsx index e3a5758..fab2fec 100644 --- a/negodata/front/src/app/provider.tsx +++ b/negodata/front/src/app/provider.tsx @@ -19,7 +19,7 @@ export function Providers({children}: {children: ReactNode}) { return ( {children} - + ); diff --git a/negodata/front/src/app/router.tsx b/negodata/front/src/app/router.tsx index 825521c..51eabf1 100644 --- a/negodata/front/src/app/router.tsx +++ b/negodata/front/src/app/router.tsx @@ -8,6 +8,7 @@ import DashboardPage from '../pages/dashboard'; import StatisticsPage from '../pages/statistics'; import ForbiddenPage from '../pages/forbidden'; import DevDesignPage from '../pages/dev-design'; +import RenegotiationPage from '../pages/renegotiation'; import NotFoundPage from '../pages/not-found'; import ProductsPage from '../pages/products'; import PartnersPage from '../pages/partners'; @@ -83,6 +84,7 @@ export const router = createBrowserRouter([ {path: 'partners', Component: PartnersPage}, {path: 'quotation', Component: QuotationPage}, {path: 'cards', Component: CardsPage}, + {path: 'renegotiation', Component: RenegotiationPage}, {path: 'notifications', Component: NotificationsPage}, { // 최고관리자 전용. 자식 loader 는 부모와 병렬 실행되므로 여기서도 initAuth 를 기다린다(멱등). diff --git a/negodata/front/src/components/layout/ActionBanner.tsx b/negodata/front/src/components/layout/ActionBanner.tsx new file mode 100644 index 0000000..01b0d54 --- /dev/null +++ b/negodata/front/src/components/layout/ActionBanner.tsx @@ -0,0 +1,107 @@ +import { useEffect, useRef } from 'react'; +import { useNavigate } from 'react-router'; +import { toast } from 'sonner'; +import { useQueryClient } from '@tanstack/react-query'; +import { useListNotifications, useReadOne } from '@/api/generated/notification/notification'; +import { useListRequests } from '@/api/generated/renegotiation/renegotiation'; +import { NotificationType } from '@/api/generated/model'; +import type { NotificationData } from '@/api/generated/model'; + +// 하단 우측 토스트로 알릴 알림 = 낙찰·결렬·자동재생성·재협상요청. 단순 생성(CREATED)은 제외. +const TOASTED: number[] = [ + NotificationType.SUCCESS, + NotificationType.FAILURE, + NotificationType.REGENERATED, + NotificationType.RENEGO_REQUESTED, +]; + +function messageFor(n: NotificationData): string { + const d = (n.data ?? {}) as Record; + const qt = String(d.qt_number ?? d.qt_name ?? '견적'); + const winner = d.winner_name ? ` · 낙찰 ${String(d.winner_name)}` : ''; + switch (n.type) { + case NotificationType.SUCCESS: + return `${qt} 낙찰되었습니다${winner}.`; + case NotificationType.FAILURE: + return `${qt} 결렬로 마감되었습니다.`; + case NotificationType.REGENERATED: + return `${qt} 다음 라운드가 자동 생성되었습니다.`; + case NotificationType.RENEGO_REQUESTED: + return `${d.supplier_name ? String(d.supplier_name) + ' · ' : ''}${qt} 재협상 요청이 접수됐습니다.`; + default: + return `${qt} 알림.`; + } +} + +// sonner 색상 함수 — 재협상요청=경고(앰버), 낙찰=성공, 결렬=에러, 자동마감=정보. +function fireOf(type?: number) { + if (type === NotificationType.SUCCESS) return toast.success; + if (type === NotificationType.FAILURE) return toast.error; + if (type === NotificationType.RENEGO_REQUESTED) return toast.warning; + return toast.info; +} + +// 알림 처리(레이아웃 상주, 화면 요소는 토스트로만). +// 새로 도착한 알림만 하단 우측 토스트로 띄우고, [보기]/[X] 로 읽음 처리(readOne API — 알림함과 동일). +// 읽으면 서버 read_at 이 박혀서 다시 뜨지 않고, 벨 배지·인박스 수도 함께 줄어든다. +export function ActionBanner() { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const readOne = useReadOne(); + + const { data: notif } = useListNotifications( + { size: 20 }, + { query: { refetchInterval: 30_000, staleTime: 10_000 } }, + ); + // 재협상 요청 토스트는 '아직 완료 안 된(대기중)' 것만 — 내가 처리 가능한(can_act) 대기 세션 집합. + const { data: renego } = useListRequests( + { status: 1, page: 1, size: 50 }, + { query: { refetchInterval: 30_000, staleTime: 10_000 } }, + ); + const pendingSessions = new Set( + (renego?.requests ?? []).filter((r) => r.can_act !== false).map((r) => r.session_id), + ); + + // 읽음 처리(서버) 후 알림 목록 무효화 → 벨 배지·인박스 수가 함께 줄어든다. + const markRead = (id: string) => { + readOne.mutate( + { notificationId: id }, + { onSuccess: () => queryClient.invalidateQueries({ queryKey: ['/v1/notification/list'], exact: false }) }, + ); + }; + + // 새로 도착한 건만 토스트(첫 로드 시점의 안읽음 백로그는 벨/인박스가 담당 — 토스트 폭탄 방지). + const seen = useRef | null>(null); + useEffect(() => { + const rows = (notif?.notifications ?? []).filter((n) => !n.read_at && TOASTED.includes(n.type)); + if (seen.current === null) { + seen.current = new Set(rows.map((n) => n.notification_id)); + return; + } + for (const n of rows) { + if (seen.current.has(n.notification_id)) continue; + const isRenego = n.type === NotificationType.RENEGO_REQUESTED; + // 재협상 요청은 아직 대기중(미완료)인 세션만 토스트 — 이미 승인/반려된 건 건너뛴다. + if (isRenego && !pendingSessions.has(n.ref_session_id ?? '')) continue; + seen.current.add(n.notification_id); + const target = isRenego ? '/renegotiation' : n.ref_qt_id ? `/quotation?detail=${n.ref_qt_id}` : '/notifications'; + fireOf(n.type)(messageFor(n), { + id: n.notification_id, + position: 'bottom-right', + duration: Infinity, // 자동으로 안 사라지고, 읽음/닫기로만 사라진다. + closeButton: true, // X = 닫기 = 읽음 처리(onDismiss). + onDismiss: () => markRead(n.notification_id), + action: { + label: isRenego ? '확인하러 가기' : '보기', + onClick: () => { + markRead(n.notification_id); // 알림함과 동일하게 읽음 API 를 찌른다. + toast.dismiss(n.notification_id); + navigate(target); + }, + }, + }); + } + }, [notif]); // eslint-disable-line react-hooks/exhaustive-deps + + return null; +} diff --git a/negodata/front/src/components/layout/AuthenticatedLayout.tsx b/negodata/front/src/components/layout/AuthenticatedLayout.tsx index 8047a66..abf8043 100644 --- a/negodata/front/src/components/layout/AuthenticatedLayout.tsx +++ b/negodata/front/src/components/layout/AuthenticatedLayout.tsx @@ -11,6 +11,7 @@ const PAGE_TO_PATH: Record = { PARTNERS: '/partners', QUOTATION: '/quotation', CARDS: '/cards', + RENEGOTIATION: '/renegotiation', MEMBERS: '/members', SETTINGS: '/settings', DESIGN: '/dev/design', diff --git a/negodata/front/src/components/layout/Layout.tsx b/negodata/front/src/components/layout/Layout.tsx index db26391..d01ce9e 100644 --- a/negodata/front/src/components/layout/Layout.tsx +++ b/negodata/front/src/components/layout/Layout.tsx @@ -10,6 +10,7 @@ import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; import { useNavigate } from 'react-router'; import { cn } from '@/lib/utils'; import { NotificationBell } from './NotificationBell'; +import { ActionBanner } from './ActionBanner'; import { GUIDE_TABS, TAB_LABEL, type GuideTab } from '@/features/onboarding/OnboardingGuideModal'; import { SETTINGS_TABS, SETTINGS_TAB_LABEL, type SettingsTab } from '@/features/settings/SettingsView'; import { @@ -27,6 +28,7 @@ import { Moon, Building, Palette, + RefreshCw, ChevronRight, Menu, X, @@ -60,6 +62,7 @@ const menuGroups: { label?: string; items: MenuItem[] }[] = [ { type: 'PARTNERS', label: '협력사관리', icon: Users, id: 'sidebar-partners' }, { type: 'QUOTATION', label: '견적관리', icon: FileSpreadsheet, id: 'sidebar-quotation' }, { type: 'CARDS', label: '협상카드관리', icon: Layers, id: 'sidebar-cards' }, + { type: 'RENEGOTIATION', label: '재협상 요청', icon: RefreshCw, id: 'sidebar-renegotiation' }, ], }, { @@ -93,6 +96,7 @@ const pageLabelMap: Record = { PARTNERS: '협력사관리', QUOTATION: '견적관리', CARDS: '협상카드관리', + RENEGOTIATION: '재협상 요청', MEMBERS: '회원관리', SETTINGS: '회사 설정', DESIGN: '디자인 시스템', @@ -333,12 +337,15 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
{children}
- {/* Compact Admin footer info */} + {/* Compact Admin footer info — 액션 배너가 뜨면 가려지지 않게 여백을 준다 */}
Copyright © O2O Inc. All rights reserved
+ {/* 처리해야 사라지는 알림(재협상 심사 대기 등) — 하단 고정 */} + + s.user?.userId); - const isSuperAdmin = useAuthStore((s) => s.user?.role === '최고관리자'); + const isSuperAdmin = useAuthStore((s) => canManage(s.user?.role)); const canMutate = mode === 'create' || (!!card && !card.isShared && (card.userId === myUserId || isSuperAdmin)); const mutateBlockReason = card?.isShared diff --git a/negodata/front/src/features/onboarding/OnboardingGuideModal.tsx b/negodata/front/src/features/onboarding/OnboardingGuideModal.tsx index a9ebeaa..6e1ce55 100644 --- a/negodata/front/src/features/onboarding/OnboardingGuideModal.tsx +++ b/negodata/front/src/features/onboarding/OnboardingGuideModal.tsx @@ -211,8 +211,8 @@ const FAQS: { q: string; a: string }[] = [ a: '견적 생성의 카드 선택에서 성공률(카드를 쓴 협상 중 타결된 비율) 높은 순으로 정렬되고, 상위 3개에는 순위 배지가 붙어요. 아직 쓰인 적 없는 카드는 표본이 없어 뒤로 밀려요.', }, { - q: '화면에 나오는 용어나 로고를 우리 회사 것으로 바꾸려면?', - a: '최고관리자 계정으로 회사 설정에 들어가면 서비스명·로고·색상(브랜딩), 화면 용어(라벨), 추가로 입력받을 항목(커스텀 필드), 감출 항목을 바꿀 수 있어요. 설정을 JSON으로 내보내고 불러올 수도 있어요.', + q: '협력사가 재협상을 요청하면 어디서 처리하나요?', + a: '낙찰 없이 개찰(결렬)로 닫힌 건에 한해, 그 견적 마지막 라운드에 참여했던 협력사가 포털에서 재협상을 요청할 수 있어요. (낙찰된 건은 제외이고, 협상을 거부했거나 미참여였던 협력사도 조건이 바뀌면 요청할 수 있어요.) 요청은 「재협상」 화면에서 현황을 보고 승인 또는 반려해요. 승인하면 그 협력사를 포함해 재협상 견적이 바로 생성되고(초청메일은 견적 상세에서 수동 발송), 반려하면 사유가 협력사에게 그대로 보여요.', }, ]; diff --git a/negodata/front/src/features/partners/components/PartnerFormSheet.tsx b/negodata/front/src/features/partners/components/PartnerFormSheet.tsx index cef7b74..cf42cca 100644 --- a/negodata/front/src/features/partners/components/PartnerFormSheet.tsx +++ b/negodata/front/src/features/partners/components/PartnerFormSheet.tsx @@ -12,7 +12,7 @@ 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 { useAuthStore, canManage } from '@/stores/auth'; import { useCompanySettings } from '@/features/settings/useCompanySettings'; import { CustomFieldInputs, useCustomFieldValues } from '@/features/settings/CustomFieldInputs'; import { SupplierItemsManager } from './SupplierItemsManager'; @@ -85,7 +85,7 @@ export function PartnerFormSheet({ }); // 협력사 명부는 회사 공유 자원 — 파괴적 삭제는 최고관리자만(백엔드 RequireOwner 와 동일 규칙). - const isSuperAdmin = useAuthStore((s) => s.user?.role === '최고관리자'); + const isSuperAdmin = useAuthStore((s) => canManage(s.user?.role)); // 회사 협력사 커스텀필드(정의=companies.settings.supplier_fields, 값=suppliers.custom) const { settings } = useCompanySettings(); diff --git a/negodata/front/src/features/partners/components/PartnerTable.tsx b/negodata/front/src/features/partners/components/PartnerTable.tsx index f6e338b..0a80f00 100644 --- a/negodata/front/src/features/partners/components/PartnerTable.tsx +++ b/negodata/front/src/features/partners/components/PartnerTable.tsx @@ -6,8 +6,9 @@ import type { Partner } from '../types'; type PartnerTableProps = { data: Partner[]; - selectedIds: string[]; - onSelectionChange: (ids: string[]) => void; + /** 미전달 시 선택(체크박스) 컬럼 자체를 숨긴다 — 일괄삭제 권한 없는 계정용 */ + selectedIds?: string[]; + onSelectionChange?: (ids: string[]) => void; onRowClick: (part: Partner) => void; page: number; totalPages: number; @@ -37,7 +38,7 @@ export function PartnerTable({ data={data} rowKey={(part) => part.supplier_id} onRowClick={onRowClick} - selection={{ selectedKeys: selectedIds, onSelectionChange }} + selection={selectedIds && onSelectionChange ? { selectedKeys: selectedIds, onSelectionChange } : undefined} empty="협약된 가용 B2B 파트너사가 존재하지 않습니다." footer={ s.user?.userId); - const isSuperAdmin = useAuthStore((s) => s.user?.role === '최고관리자'); + const isSuperAdmin = useAuthStore((s) => canManage(s.user?.role)); const canManageOwn = !!product && (product.user_id === myUserId || isSuperAdmin); // 저장 가능 여부 — 신규는 항상, 수정은 소유자/관리자만. const canSave = mode === 'create' || canManageOwn; diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/index.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/index.tsx index c7c8967..cbfad3a 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/index.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/index.tsx @@ -12,7 +12,7 @@ import { useListSuppliers } from '@/api/generated/supplier/supplier'; import { useListSettings } from '@/api/generated/quotation-setting/quotation-setting'; import { useQuotationChain } from '../../hooks/useQuotationChain'; import { useScrollLock } from '@/lib/useScrollLock'; -import { useAuthStore } from '@/stores/auth'; +import { useAuthStore, canManage } from '@/stores/auth'; import type { QuotationData } from '@/api/generated/model/quotationData'; import { mapItem, @@ -77,7 +77,7 @@ export function QuotationDetailSheet({ // 소유자 게이팅 — 견적을 바꾸는 액션(초청메일·마감·재생성·낙찰)은 '본인 견적' 또는 최고관리자만. // 프론트 1차 차단이며, 실제 보안은 백엔드가 동일 스코프로 강제해야 함(버튼 숨김만으론 우회 가능). const myUserId = useAuthStore((s) => s.user?.userId); - const isSuperAdmin = useAuthStore((s) => s.user?.role === '최고관리자'); + const isSuperAdmin = useAuthStore((s) => canManage(s.user?.role)); const canManage = !!myUserId && (quotation.user_id === myUserId || isSuperAdmin); const canNotify = canManage; // 초청 메일 발송/재발송 // 직접 낙찰 = 개찰(낙찰자 미정 마감) 견적에서만. 후보(투찰한 협상완료 협력사) 유무는 표에서 판정. diff --git a/negodata/front/src/features/renegotiation/components/RenegotiationReviewSheet.tsx b/negodata/front/src/features/renegotiation/components/RenegotiationReviewSheet.tsx new file mode 100644 index 0000000..b782d9d --- /dev/null +++ b/negodata/front/src/features/renegotiation/components/RenegotiationReviewSheet.tsx @@ -0,0 +1,147 @@ +import { useState } from 'react'; +import { Link } from 'react-router'; +import { showToast } from '@/lib/notify'; +import { Sheet } from '@/components/ui/sheet'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Typography, typographyVariants } from '@/components/ui/typography'; +import { cn } from '@/lib/utils'; +import { CLOSE_REASON_LABEL, RENEGO_STATUS_LABEL, RenegoStatus, type RenegoRequest } from '../types'; + +type Props = { + open: boolean; + request: RenegoRequest; + onApprove: (memo: string) => Promise; + onReject: (memo: string) => Promise; + onClose: () => void; +}; + +const won = (v?: number | null) => (v != null ? `₩${Number(v).toLocaleString()}` : '-'); + +// 재협상 심사 패널. 담당자가 승인/반려를 결정하는 데 필요한 근거(원 견적 결과 + 요청 내용)를 한 화면에 모은다. +export function RenegotiationReviewSheet({ open, request, onApprove, onReject, onClose }: Props) { + const [memo, setMemo] = useState(''); + const [busy, setBusy] = useState(false); + const pending = request.status === RenegoStatus.PENDING; + + // 직전 투찰가 대비 희망가가 얼마나 내려오는지 — 승인 판단의 핵심 숫자. + const drop = + request.bid_price && request.desired_price + ? (1 - request.desired_price / request.bid_price) * 100 + : null; + + const run = async (kind: 'approve' | 'reject') => { + if (kind === 'reject' && !memo.trim()) { + showToast('반려 사유를 입력해 주십시오. 공급사에게 그대로 전달됩니다.', 'error'); + return; + } + setBusy(true); + try { + await (kind === 'approve' ? onApprove(memo.trim()) : onReject(memo.trim())); + onClose(); + } finally { + setBusy(false); + } + }; + + return ( + +
+ + + + + +
+ 요청 내용 + + + + + {drop != null && ( + 0 ? 'text-emerald-600' : 'text-rose-600')}> + {drop > 0 + ? `직전 투찰가보다 ${drop.toFixed(1)}% 낮은 금액을 제시했습니다.` + : `직전 투찰가보다 높거나 같은 금액입니다 — 재협상 실익을 확인하십시오.`} + + )} +
+ + {pending ? ( + request.can_act === false ? ( + // 남의 견적 요청 — 현황은 보이되 처리(승인/반려)는 견적 작성자 본인·최고관리자만. + + 이 견적의 담당자(작성자) 또는 최고관리자만 승인·반려할 수 있습니다. 현황 확인만 가능합니다. + + ) : ( + <> +
+ + 메모 (반려 시 필수 — 공급사에게 노출) + + setMemo(e.target.value)} placeholder="예: 목표가와 격차가 커 이번 건은 종료합니다" /> +
+ + 승인하면 이 공급사를 포함해 재협상 견적이 즉시 생성됩니다. 초청메일은 자동 발송되지 않으니 견적 상세에서 보내십시오. + +
+ + +
+ + ) + ) : ( +
+ + {request.decided_by_name && } + {request.memo && } + {request.next_quotation_id && ( + + 생성된 재협상 견적 보기 ↗ + + )} +
+ )} +
+
+ ); +} + +function Row({ label, value, strong, href }: { label: string; value: string; strong?: boolean; href?: string }) { + return ( +
+ {label} + {href ? ( + + {value} ↗ + + ) : ( + + {value} + + )} +
+ ); +} diff --git a/negodata/front/src/features/renegotiation/components/RenegotiationTable.tsx b/negodata/front/src/features/renegotiation/components/RenegotiationTable.tsx new file mode 100644 index 0000000..9c5337d --- /dev/null +++ b/negodata/front/src/features/renegotiation/components/RenegotiationTable.tsx @@ -0,0 +1,122 @@ +import { DataTable, type Column } from '@/components/ui/data-table'; +import { TablePagination } from '@/components/ui/table-pagination'; +import { Typography } from '@/components/ui/typography'; +import { StatusPill } from '@/features/quotations/components/QuotationDetailSheet/StatusPill'; +import { CLOSE_REASON_LABEL, RENEGO_STATUS_LABEL, RenegoStatus, type RenegoRequest } from '../types'; + +type Props = { + data: RenegoRequest[]; + onRowClick: (req: RenegoRequest) => void; + page: number; + totalPages: number; + totalCount: number; + pageSize: number; + onPageChange: (page: number) => void; + className?: string; +}; + +const won = (v?: number | null) => (v != null ? `₩${Number(v).toLocaleString()}` : '-'); +// ISO(UTC) → 'MM-DD HH:mm'. 목록에선 연도까지 필요 없다. +const shortTime = (iso?: string | null) => + iso ? new Date(iso).toLocaleString('sv-SE').slice(5, 16) : '-'; + +const statusTone = (status: number) => { + if (status === RenegoStatus.PENDING) return 'amber' as const; + if (status === RenegoStatus.APPROVED) return 'emerald' as const; + if (status === RenegoStatus.REJECTED) return 'rose' as const; + return 'zinc' as const; +}; + +export function RenegotiationTable({ data, onRowClick, page, totalPages, totalCount, pageSize, onPageChange, className }: Props) { + return ( + r.session_id ?? ''} + onRowClick={onRowClick} + empty="접수된 재협상 요청이 없습니다." + footer={ + + } + columns={[ + { + header: '요청일시', + align: 'left', + cellClassName: 'font-mono text-muted-foreground whitespace-nowrap', + cell: (r) => shortTime(r.requested_at), + }, + { + header: '공급사', + align: 'left', + mobileHeader: true, + cell: (r) => ( +
+ + {r.supplier_name || '-'} + + + {r.item_name || '-'} + +
+ ), + }, + { + header: '견적', + align: 'left', + cell: (r) => ( +
+ + {r.qt_number} / {r.qt_round}차 + + + 마감: {CLOSE_REASON_LABEL[r.close_reason ?? 0] ?? '-'} + +
+ ), + }, + { + header: '담당자', + align: 'left', + cellClassName: 'whitespace-nowrap', + cell: (r) => r.owner_name || '-', + }, + { header: '요청 사유', align: 'left', cell: (r) => r.reason || '-' }, + { + header: '희망가', + align: 'right', + cellClassName: 'font-mono', + cell: (r) => won(r.desired_price), + }, + { + header: '직전 투찰가', + align: 'right', + cellClassName: 'font-mono text-muted-foreground', + cell: (r) => won(r.bid_price), + }, + { + header: '상태', + align: 'center', + cell: (r) => ( + + {RENEGO_STATUS_LABEL[r.status ?? 0] ?? '-'} + + ), + }, + { + header: '처리 담당자', + align: 'left', + cellClassName: 'whitespace-nowrap text-muted-foreground', + cell: (r) => r.decided_by_name || '-', + }, + ] as Column[]} + /> + ); +} diff --git a/negodata/front/src/features/renegotiation/types.ts b/negodata/front/src/features/renegotiation/types.ts new file mode 100644 index 0000000..65369c3 --- /dev/null +++ b/negodata/front/src/features/renegotiation/types.ts @@ -0,0 +1,23 @@ +import type { RenegotiationData } from '@/api/generated/model'; + +// 재협상 요청 상태(sessions.custom.renegotiation.status). 백엔드 RenegotiationStatus 와 1:1. +export const RenegoStatus = { PENDING: 1, APPROVED: 2, REJECTED: 3, CANCELED: 4 } as const; +export type RenegoStatusCode = (typeof RenegoStatus)[keyof typeof RenegoStatus]; + +export const RENEGO_STATUS_LABEL: Record = { + [RenegoStatus.PENDING]: '심사 대기', + [RenegoStatus.APPROVED]: '승인', + [RenegoStatus.REJECTED]: '반려', + [RenegoStatus.CANCELED]: '철회', +}; + +// 마감 사유 — 요청이 걸린 원 견적이 왜 결렬됐는지(담당자 판단 근거). +export const CLOSE_REASON_LABEL: Record = { + 1: '낙찰', + 5: '기준 미달', + 6: '동가', + 7: '전원 미응찰', + 8: '협상거부', +}; + +export type RenegoRequest = RenegotiationData; diff --git a/negodata/front/src/features/settings/CustomFieldInputs.tsx b/negodata/front/src/features/settings/CustomFieldInputs.tsx index b2c7e38..901805b 100644 --- a/negodata/front/src/features/settings/CustomFieldInputs.tsx +++ b/negodata/front/src/features/settings/CustomFieldInputs.tsx @@ -49,6 +49,20 @@ export function CustomFieldInputs({ fields, state, title = '회사 추가 항목 /> + ) : f.type === 'select' ? ( + <> + {f.label} + + ) : ( <> {f.label} diff --git a/negodata/front/src/features/settings/SettingsView.tsx b/negodata/front/src/features/settings/SettingsView.tsx index c3f17da..99bdefe 100644 --- a/negodata/front/src/features/settings/SettingsView.tsx +++ b/negodata/front/src/features/settings/SettingsView.tsx @@ -1,6 +1,6 @@ import { Fragment, useEffect, useMemo, useRef, useState } from 'react'; import { useSearchParams } from 'react-router'; -import { Palette, Tags, ListPlus, Plus, Trash2, RotateCcw, Download, Upload } from 'lucide-react'; +import { Palette, Tags, ListPlus, Plus, Trash2, RotateCcw, Download, Upload, X } from 'lucide-react'; import { showToast } from '@/lib/notify'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -180,30 +180,6 @@ export function SettingsView() { placeholder="NegoData (기본값)" /> - -
- setBranding('primary_color', e.target.value)} - /> - setBranding('primary_color', e.target.value)} - placeholder="#5E6AD2" - /> -
-
- - setBranding('email_header', e.target.value)} - placeholder="NEGODATA (기본값)" - /> - {/* 로고 이미지 — 업로드(드롭/선택) 또는 URL 직접 입력. 비우면 색상 마크+서비스명 텍스트. */} @@ -430,13 +406,14 @@ function CustomFieldsEditor({ 표시명 키 (영문) 유형 + 보기 목록 (선택형, 쉼표로 구분) 삭제 {fields.length === 0 && ( - + 추가된 커스텀 필드가 없습니다. @@ -476,6 +453,13 @@ function CustomFieldsEditor({ + + update(i, { options: opts })} + /> + + + ))} + (e.target.value.includes(',') ? add(e.target.value.replace(/,/g, '')) : setDraft(e.target.value))} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.nativeEvent.isComposing) { + e.preventDefault(); + add(draft); + } else if (e.key === 'Backspace' && !draft && options.length) { + onChange(options.slice(0, -1)); + } + }} + onBlur={() => add(draft)} + placeholder={options.length ? '추가…' : '예: 협력사배송 (엔터로 추가)'} + /> + + ); +} + // key 입력 정리 — 영문/숫자/언더스코어만 허용(소문자화). function sanitizeKey(raw: string): string { return raw.toLowerCase().replace(/[^a-z0-9_]/g, ''); diff --git a/negodata/front/src/features/settings/catalog.ts b/negodata/front/src/features/settings/catalog.ts index 3a56d01..13e8bc4 100644 --- a/negodata/front/src/features/settings/catalog.ts +++ b/negodata/front/src/features/settings/catalog.ts @@ -1,12 +1,13 @@ // 회사 커스터마이징 설정(companies.settings JSONB) 문서 타입 + 용어 라벨 카탈로그. // 라벨 키는 여기 한 곳에만 추가한다 — 설정 화면(용어 탭)과 화면 배선(useLabel)이 같은 카탈로그를 읽는다. -export type CustomFieldType = 'text' | 'number' | 'boolean'; +export type CustomFieldType = 'text' | 'number' | 'boolean' | 'select'; export type CustomFieldDef = { key: string; // custom JSONB 의 키 (영문 snake_case) label: string; // 화면 표시명 type: CustomFieldType; + options?: string[]; // type='select' 일 때 고를 보기 목록 }; export type CompanySettings = { @@ -130,4 +131,5 @@ export const CUSTOM_FIELD_TYPE_LABEL: Record = { text: '텍스트', number: '숫자', boolean: '예/아니오', + select: '선택', }; diff --git a/negodata/front/src/pages/notifications.tsx b/negodata/front/src/pages/notifications.tsx index ac7b0d5..65f8803 100644 --- a/negodata/front/src/pages/notifications.tsx +++ b/negodata/front/src/pages/notifications.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState, type ReactNode } from 'react'; import { useNavigate } from 'react-router'; import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; -import { Trophy, RefreshCw, XCircle, Bell, CheckCheck, FilePlus2 } from 'lucide-react'; +import { CheckCircle2, RefreshCw, XCircle, Bell, CheckCheck, FilePlus2, Handshake } from 'lucide-react'; import { PageContainer } from '@/components/layout/PageContainer'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -295,7 +295,7 @@ function render(n: NotificationData): { case NotificationType.SUCCESS: // 자동 낙찰과 담당자 직접 낙찰(data.manual)은 같은 SUCCESS — 문구로만 '직접'을 구분한다. return { - icon: , + icon: , pill: d.manual ? '낙찰 · 직접' : '낙찰', pillCls: PILL_TONE.emerald, name, @@ -325,6 +325,18 @@ function render(n: NotificationData): { }`, number, }; + case NotificationType.RENEGO_REQUESTED: { + const reason = d.reason ? `사유 ${String(d.reason)}` : ''; + const want = d.desired_price != null ? `희망가 ${Number(d.desired_price).toLocaleString()}원` : ''; + return { + icon: , + pill: '재협상 요청', + pillCls: PILL_TONE.indigo, + name: (d.supplier_name as string) || name, + detail: [reason, want].filter(Boolean).join(' · ') || '공급사가 재협상을 요청했습니다', + number, + }; + } default: return { icon: , pill: '알림', pillCls: PILL_TONE.muted, name, detail: '', number }; } diff --git a/negodata/front/src/pages/partners.tsx b/negodata/front/src/pages/partners.tsx index 91da63a..c747376 100644 --- a/negodata/front/src/pages/partners.tsx +++ b/negodata/front/src/pages/partners.tsx @@ -7,7 +7,7 @@ import { PageContainer } from '@/components/layout/PageContainer'; import { PageToolbar, SearchInput } from '@/components/layout/PageToolbar'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; -import { useAuthStore } from '@/stores/auth'; +import { useAuthStore, canManage } from '@/stores/auth'; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu'; import { useServerList } from '@/lib/useServerList'; import { usePartners } from '@/features/partners/hooks/usePartners'; @@ -40,7 +40,7 @@ export default function PartnersPage() { const isFormOpen = overlay.has('new') || !!editing; // 협력사 삭제는 최고관리자 전용(단건 삭제와 동일 규칙) — 일괄삭제 버튼도 최고관리자에게만 노출. - const isSuperAdmin = useAuthStore((st) => st.user?.role === '최고관리자'); + const isSuperAdmin = useAuthStore((st) => canManage(st.user?.role)); const [selectedIds, setSelectedIds] = useState([]); const handleBulkDelete = async () => { @@ -133,8 +133,8 @@ export default function PartnersPage() { t.id === activeTab)?.status; + + const { data } = useListRequests({ status, page: list.page, size: list.pageSize }); + const requests = (data?.requests ?? []) as RenegoRequest[]; + const total = data?.total ?? 0; + const totalPages = list.totalPages(total); + + const overlay = useOverlayRouter(['detail']); + const detailId = overlay.get('detail'); + const active = detailId ? requests.find((r) => r.session_id === detailId) ?? null : null; + + const refresh = () => queryClient.invalidateQueries({ queryKey: getListRequestsQueryKey() }); + + const handleApprove = async (memo: string) => { + if (!active?.session_id) return; + const res = await approve(active.session_id, { memo, supplier_ids: [] }); + if (res.result?.success === false) { + showToast(res.msg || '승인에 실패했습니다.', 'error'); + return; + } + showToast('승인했습니다. 다음 차수 견적이 생성되었습니다 — 초청메일을 발송해 주십시오.', 'success'); + await refresh(); + }; + + const handleReject = async (memo: string) => { + if (!active?.session_id) return; + const res = await reject(active.session_id, { memo }); + if (res.result?.success === false) { + showToast(res.msg || '반려에 실패했습니다.', 'error'); + return; + } + showToast('반려했습니다. 사유가 공급사에게 전달됩니다.', 'success'); + await refresh(); + }; + + return ( + +
+ + + 협력사가 결렬(개찰) 건에 대해 다시 협상하자고 요청한 목록입니다. 승인하면 다음 차수 견적이 생성됩니다. + + + +
+ {TABS.map((tab) => ( + + ))} +
+ + r.session_id && overlay.open('detail', r.session_id)} + page={list.page} + totalPages={totalPages} + totalCount={total} + pageSize={list.pageSize} + onPageChange={list.setPage} + /> +
+ + {active && ( + + )} +
+ ); +} diff --git a/negodata/front/src/pages/statistics.tsx b/negodata/front/src/pages/statistics.tsx index 94f516c..f3c0ff0 100644 --- a/negodata/front/src/pages/statistics.tsx +++ b/negodata/front/src/pages/statistics.tsx @@ -3,13 +3,14 @@ import { PageContainer } from '@/components/layout/PageContainer'; import { Typography } from '@/components/ui/typography'; import { Button } from '@/components/ui/button'; import { useAuth } from '@/features/auth/useAuth'; +import { canManage } from '@/stores/auth'; import { StatisticsView, useStatistics, type Scope } from '@/features/statistics'; // 통계(성과 분석). 회사 전체 스코프는 최고관리자만, 일반 사용자는 '내 견적'만 본다. // 데이터는 백엔드 파생 집계(/v1/statistics/summary) — 최근 6개월 창. export default function StatisticsPage() { const { user } = useAuth(); - const isOwner = user?.role === '최고관리자'; + const isOwner = canManage(user?.role); const [scope, setScope] = useState('company'); const activeScope: Scope = isOwner ? scope : 'mine'; diff --git a/negodata/front/src/stores/auth.ts b/negodata/front/src/stores/auth.ts index 35a9eca..52d3bf0 100644 --- a/negodata/front/src/stores/auth.ts +++ b/negodata/front/src/stores/auth.ts @@ -23,6 +23,8 @@ export const useAuthStore = create((set) => ({ })); export const isLoggedIn = () => useAuthStore.getState().user !== null; +// 레벨2 이상(최고관리자·개발자) — 변경 액션 게이트. 백엔드 authz(role >= OWNER)·메뉴 노출(Layout ownerOnly)과 같은 기준. +export const canManage = (role?: UserRole | null) => role === '최고관리자' || role === '개발자'; export const hasRole = (...roles: UserRole[]) => { const u = useAuthStore.getState().user; return u ? roles.includes(u.role) : false; diff --git a/negodata/front/src/types.ts b/negodata/front/src/types.ts index ebb9429..3f5fe22 100644 --- a/negodata/front/src/types.ts +++ b/negodata/front/src/types.ts @@ -37,4 +37,4 @@ export interface NegotiationCard { usedCount: number; // 카드 사용 세션 수(표본) } -export type PageType = 'DASHBOARD' | 'STATISTICS' | 'PRODUCTS' | 'PARTNERS' | 'QUOTATION' | 'CARDS' | 'MEMBERS' | 'SETTINGS' | 'DESIGN' | 'NOTIFICATIONS'; +export type PageType = 'DASHBOARD' | 'STATISTICS' | 'PRODUCTS' | 'PARTNERS' | 'QUOTATION' | 'CARDS' | 'RENEGOTIATION' | 'MEMBERS' | 'SETTINGS' | 'DESIGN' | 'NOTIFICATIONS';