diff --git a/negodata/backend/common/database/model/models.py b/negodata/backend/common/database/model/models.py index b4fcbce..8d4d2d1 100644 --- a/negodata/backend/common/database/model/models.py +++ b/negodata/backend/common/database/model/models.py @@ -132,6 +132,31 @@ class wild_cards(MainTableMixin, MAIN_BASE): memo = Column(String(255), nullable=True) # 자유 메모 +class versions(MainTableMixin, MAIN_BASE): + __tablename__ = "versions" + + version_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 버전 생성 유저 + code = Column(Integer, nullable=False, default=0) # 빠른 조회용 + name = Column(String(10), nullable=False) # 버전명 + + +class version_nego_cards(MainTableMixin, MAIN_BASE): + __tablename__ = "version_nego_cards" + + vnc_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + version_id = Column(UUID(as_uuid=True), nullable=False, index=True) # versions.version_id + nego_card_id = Column(UUID(as_uuid=True), nullable=False, index=True) # nego_cards.nego_card_id + + +class version_wild_cards(MainTableMixin, MAIN_BASE): + __tablename__ = "version_wild_cards" + + vwc_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + version_id = Column(UUID(as_uuid=True), nullable=False, index=True) # versions.version_id + wild_card_id = Column(UUID(as_uuid=True), nullable=False, index=True) # wild_cards.wild_card_id + + class quotation_settings(MainTableMixin, MAIN_BASE): __tablename__ = "quotation_settings" diff --git a/negodata/backend/config/config.local.toml.example b/negodata/backend/config/config.local.toml.example index 6bc9c59..7099edd 100644 --- a/negodata/backend/config/config.local.toml.example +++ b/negodata/backend/config/config.local.toml.example @@ -8,6 +8,7 @@ process_count = 1 is_ssl = false is_test = true client_url = "http://localhost:3000" # CORS 허용 +nego_chat_url = "http://localhost:3300" # 공급사 협상 프론트 base(세션 chat 실행 URL). 미설정 시 기본값 동일. [LogConfig] print_console = true diff --git a/negodata/backend/config/config_models.py b/negodata/backend/config/config_models.py index 3610dc5..97d11ac 100644 --- a/negodata/backend/config/config_models.py +++ b/negodata/backend/config/config_models.py @@ -8,6 +8,7 @@ class WebServerConfig(ConfigModel): is_ssl: bool = False is_test: bool = False client_url: str = "" + nego_chat_url: str = "http://localhost:3300" class LogConfig(ConfigModel): diff --git a/negodata/backend/crud/quotation_crud.py b/negodata/backend/crud/quotation_crud.py index 025dcf0..0bfb93f 100644 --- a/negodata/backend/crud/quotation_crud.py +++ b/negodata/backend/crud/quotation_crud.py @@ -6,7 +6,10 @@ from sqlalchemy import select, func, and_, update from sqlalchemy.ext.asyncio import AsyncSession from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import quotations, sessions, chats, nego_cards, wild_cards +from common.database.model.models import ( + quotations, sessions, chats, nego_cards, wild_cards, items, quotation_settings, + version_nego_cards, version_wild_cards, +) from common.enums import ErrorType from common.logger import LOG from common.utils.gtime import GTime @@ -28,6 +31,30 @@ class IQuotationCRUD(ABC): async def add_quotation(self, cdb: AsyncSession, quotation: quotations) -> ErrorType: pass + @abstractmethod + async def add_sessions(self, cdb: AsyncSession, session_list: list) -> ErrorType: + pass + + @abstractmethod + async def get_item_prices(self, cdb: AsyncSession, item_ids) -> Tuple[ErrorType, dict]: + pass + + @abstractmethod + async def get_target_margin(self, cdb: AsyncSession, qt_setting_id) -> Tuple[ErrorType, Optional[float]]: + pass + + @abstractmethod + async def add_rows(self, cdb: AsyncSession, obj_list: list) -> ErrorType: + pass + + @abstractmethod + async def classify_card_ids(self, cdb: AsyncSession, card_ids) -> Tuple[ErrorType, dict]: + pass + + @abstractmethod + async def get_version_cards(self, cdb: AsyncSession, version_id) -> Tuple[ErrorType, list]: + pass + @abstractmethod async def update_quotation(self, cdb: AsyncSession, qt_id, data: dict) -> ErrorType: pass @@ -52,6 +79,10 @@ class IQuotationCRUD(ABC): async def session_counts(self, cdb: AsyncSession, qt_ids) -> Tuple[ErrorType, dict]: pass + @abstractmethod + async def item_map(self, cdb: AsyncSession, qt_ids) -> Tuple[ErrorType, dict]: + pass + class QuotationCRUD(IQuotationCRUD): async def search( @@ -110,6 +141,32 @@ class QuotationCRUD(IQuotationCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, {} + async def item_map(self, cdb: AsyncSession, qt_ids) -> Tuple[ErrorType, dict]: + """견적 id 목록에 대해 대표 상품(세션의 첫 item) {qt_id: (item_id, item_name)} 을 한 번에 가져온다.""" + try: + if not qt_ids: + return ErrorType.SUCCESS, {} + query = ( + select(sessions.quotation_id, sessions.item_id, items.name) + .join(items, items.item_id == sessions.item_id) + .where( + sessions.quotation_id.in_(qt_ids), + sessions.deleted == False, # noqa: E712 + items.deleted == False, # noqa: E712 + ) + ) + err_type, rows = await DB_SESSION_MNG.execute(cdb, query) + if err_type != ErrorType.SUCCESS: + return err_type, {} + result = {} + for qid, iid, iname in rows: + if qid not in result: # 견적당 대표 1개(첫 세션 상품) + result[qid] = (iid, iname) + return ErrorType.SUCCESS, result + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, {} + async def get_by_id(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, quotations]: try: query = select(quotations).where(quotations.qt_id == qt_id, quotations.deleted == False).limit(1) # noqa: E712 @@ -130,6 +187,117 @@ class QuotationCRUD(IQuotationCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED + async def add_sessions(self, cdb: AsyncSession, session_list: list) -> ErrorType: + """견적 생성 시 만들어진 협상 세션들을 한 번에 insert. 빈 목록이면 그냥 통과.""" + try: + if not session_list: + return ErrorType.SUCCESS + return await DB_SESSION_MNG.insert(cdb, session_list) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED + + async def add_rows(self, cdb: AsyncSession, obj_list: list) -> ErrorType: + """임의 ORM 행 묶음 insert(버전/버전-카드 매핑 등). 빈 목록이면 통과.""" + try: + if not obj_list: + return ErrorType.SUCCESS + return await DB_SESSION_MNG.insert(cdb, obj_list) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED + + async def classify_card_ids(self, cdb: AsyncSession, card_ids) -> Tuple[ErrorType, dict]: + """선택 카드 id 를 협상(1)/와일드(2)로 분류. {card_id: card_type}.""" + try: + if not card_ids: + return ErrorType.SUCCESS, {} + out = {} + n_err, n_rows = await DB_SESSION_MNG.execute( + cdb, select(nego_cards.nego_card_id).where(nego_cards.nego_card_id.in_(card_ids), nego_cards.deleted == False) # noqa: E712 + ) + if n_err != ErrorType.SUCCESS: + return n_err, {} + for r in n_rows: + out[r] = 1 + w_err, w_rows = await DB_SESSION_MNG.execute( + cdb, select(wild_cards.wild_card_id).where(wild_cards.wild_card_id.in_(card_ids), wild_cards.deleted == False) # noqa: E712 + ) + if w_err != ErrorType.SUCCESS: + return w_err, {} + for r in w_rows: + out[r] = 2 + return ErrorType.SUCCESS, out + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, {} + + async def get_version_cards(self, cdb: AsyncSession, version_id) -> Tuple[ErrorType, list]: + """견적 버전에 묶인 카드. version_nego_cards/version_wild_cards 조인. + 반환: [(card_type, card_pk, number, name, script, edit_script, condition, memo), ...].""" + try: + out = [] + n_q = ( + select( + nego_cards.nego_card_id, nego_cards.number, nego_cards.name, + nego_cards.script, nego_cards.edit_script, + ) + .join(version_nego_cards, version_nego_cards.nego_card_id == nego_cards.nego_card_id) + .where(version_nego_cards.version_id == version_id, version_nego_cards.deleted == False, nego_cards.deleted == False) # noqa: E712 + ) + n_err, n_rows = await DB_SESSION_MNG.execute(cdb, n_q) + if n_err != ErrorType.SUCCESS: + return n_err, [] + for pk, number, name, script, edit in n_rows: + out.append((1, pk, number, name, script, edit, None, None)) + w_q = ( + select( + wild_cards.wild_card_id, wild_cards.number, wild_cards.name, + wild_cards.script, wild_cards.edit_script, wild_cards.condition, wild_cards.memo, + ) + .join(version_wild_cards, version_wild_cards.wild_card_id == wild_cards.wild_card_id) + .where(version_wild_cards.version_id == version_id, version_wild_cards.deleted == False, wild_cards.deleted == False) # noqa: E712 + ) + w_err, w_rows = await DB_SESSION_MNG.execute(cdb, w_q) + if w_err != ErrorType.SUCCESS: + return w_err, [] + for pk, number, name, script, edit, condition, memo in w_rows: + out.append((2, pk, number, name, script, edit, condition, memo)) + return ErrorType.SUCCESS, out + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, [] + + async def get_item_prices(self, cdb: AsyncSession, item_ids) -> Tuple[ErrorType, dict]: + """item_id -> price(원, NULL 가능) 매핑. 세션 목표가 계산 입력.""" + try: + if not item_ids: + return ErrorType.SUCCESS, {} + query = select(items.item_id, items.price).where( + items.item_id.in_(item_ids), items.deleted == False # noqa: E712 + ) + err_type, rows = await DB_SESSION_MNG.execute(cdb, query) + if err_type != ErrorType.SUCCESS: + return err_type, {} + return ErrorType.SUCCESS, {r[0]: r[1] for r in rows} + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, {} + + async def get_target_margin(self, cdb: AsyncSession, qt_setting_id) -> Tuple[ErrorType, Optional[float]]: + """견적 세팅의 목표 마진율. 세션 목표가 = price / (1 + margin).""" + try: + query = select(quotation_settings.target_margin_rate).where( + quotation_settings.qt_setting_id == qt_setting_id + ).limit(1) + err_type, rows = await DB_SESSION_MNG.execute(cdb, query) + if err_type != ErrorType.SUCCESS: + return err_type, None + return ErrorType.SUCCESS, (float(rows[0]) if rows and rows[0] is not None else None) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, None + async def update_quotation(self, cdb: AsyncSession, qt_id, data: dict) -> ErrorType: try: if not data: diff --git a/negodata/backend/router/v1/quotation/protocol.py b/negodata/backend/router/v1/quotation/protocol.py index 1bb3812..3c53e0d 100644 --- a/negodata/backend/router/v1/quotation/protocol.py +++ b/negodata/backend/router/v1/quotation/protocol.py @@ -13,18 +13,21 @@ class QuotationProtocol(WebPacketProtocol): class Req_CreateQuotation(QuotationProtocol): qt_setting_id: uuid.UUID - version_id: uuid.UUID + version_id: Optional[uuid.UUID] = None # 미지정 시 기본 전략 버전(card.versions 시드)으로 채움 name: str = "" number: str = "" type: int = 0 status: int = 0 - start_time: datetime + start_time: Optional[datetime] = None # 미지정 시 생성 시각(UTC) end_time: datetime round: int = 1 manager_name: Optional[str] = None manager_email: Optional[str] = None manager_contact_number: Optional[str] = None memo: Optional[str] = None + item_ids: list[uuid.UUID] = [] # 협상 대상 상품. item×supplier 조합마다 세션 1개 생성 + supplier_ids: list[uuid.UUID] = [] # 협상 초청 공급사 + card_ids: list[uuid.UUID] = [] # 선택 협상카드. 버전을 만들어 묶고 quotation.version_id 로 연결 class QuotationData(WebPacketProtocol): @@ -52,6 +55,8 @@ class QuotationData(WebPacketProtocol): equal_bid_yn: Optional[bool] = None equal_bid_data: Optional[Any] = None participation_count: int = 0 # 견적별 참여 협력사 수(세션 distinct supplier). 목록 집계로 채움. + item_id: Optional[uuid.UUID] = None # 대표 상품 id(세션의 첫 item). 목록 조인으로 채움. + item_name: Optional[str] = None # 대표 상품명. 목록 조인으로 채움. created_at: Optional[datetime] = None updated_at: Optional[datetime] = None @@ -68,22 +73,6 @@ class Res_DeleteQuotation(Res_WebPacketProtocol): pass -class AsyncJob(WebPacketProtocol): - status: str = "" - message: str = "" - - -class Res_CreateQuotation(Res_WebPacketProtocol): - quotation: Optional[QuotationData] = None - async_job: Optional[AsyncJob] = None - - -class Res_QuotationStatus(Res_WebPacketProtocol): - qt_id: Optional[uuid.UUID] = None - job_status: int = 0 - message: str = "" - - class SessionData(WebPacketProtocol): model_config = ConfigDict(from_attributes=True) @@ -102,6 +91,24 @@ class SessionData(WebPacketProtocol): reject_reason: Optional[str] = None reject_price: Optional[int] = None reject_delivery_type: Optional[int] = None + url: str = "" # 세션 chat 실행 URL(공급사 협상 프론트). DB 미저장 — session_id 로 구성 + + +class AsyncJob(WebPacketProtocol): + status: str = "" + message: str = "" + + +class Res_CreateQuotation(Res_WebPacketProtocol): + quotation: Optional[QuotationData] = None + sessions: list[SessionData] = [] # 견적 생성과 함께 만들어진 협상 세션(각 url 포함) + async_job: Optional[AsyncJob] = None + + +class Res_QuotationStatus(Res_WebPacketProtocol): + qt_id: Optional[uuid.UUID] = None + job_status: int = 0 + message: str = "" class Res_QuotationSessions(Res_WebPacketProtocol): diff --git a/negodata/backend/services/quotation_service.py b/negodata/backend/services/quotation_service.py index 86b92db..462166b 100644 --- a/negodata/backend/services/quotation_service.py +++ b/negodata/backend/services/quotation_service.py @@ -1,11 +1,14 @@ import uuid +from datetime import timezone from fastapi import Depends from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import quotations, sessions, chats -from common.enums import DBWRType, ErrorType +from common.database.model.models import quotations, sessions, chats, versions, version_nego_cards, version_wild_cards +from common.enums import DBWRType, ErrorType, QuotationStatus, SessionStatus from common.models.gmodel import PageParams +from common.utils.gtime import GTime +from config.server_configs import web_server_config from crud.quotation_crud import IQuotationCRUD, QuotationCRUD from router.v1.quotation.protocol import ( AsyncJob, @@ -32,9 +35,42 @@ class QuotationService: user_id 는 생성 시 소유자로만 기록한다(조회/변경 시 소유권 필터 없음). """ + # 기본 전략 버전(card.versions 시드). 견적 생성 시 version_id 미지정이면 이 값으로 채운다. + DEFAULT_VERSION_ID = uuid.UUID("00000000-0000-0000-0000-000000000030") + def __init__(self, quotation_crud: IQuotationCRUD = Depends(QuotationCRUD)): self.quotation_crud = quotation_crud + @staticmethod + def _session_chat_url(session_id) -> str: + """세션 chat 실행 URL(공급사 협상 프론트). ChatPage 가 session_id 쿼리로 진입한다.""" + base = (web_server_config.nego_chat_url or "").rstrip("/") + return f"{base}/chat?session_id={session_id}" + + @staticmethod + def _calc_target_price(price, margin) -> int: + """세션 목표가(원). 단가 있으면 목표 마진율 적용가, 없으면 0.""" + if not price: + return 0 + if margin and margin > 0: + return int(int(price) / (1 + margin)) + return int(price) + + @staticmethod + def _naive_utc(dt): + """DB 컬럼이 naive(TIMESTAMP WITHOUT TIME ZONE)라, tz-aware 입력(프론트 toISOString 등)은 UTC naive 로 변환.""" + if dt is None: + return dt + if getattr(dt, "tzinfo", None) is not None: + return dt.astimezone(timezone.utc).replace(tzinfo=None) + return dt + + @staticmethod + def _gen_number() -> str: + """견적번호 자동 생성(미지정 시). EST-YYYYMM-XXXX.""" + now = GTime.UTC() + return f"EST-{now:%Y%m}-{uuid.uuid4().hex[:4].upper()}" + async def _fetch(self, qt_id: uuid.UUID): """견적 단건 조회. (ErrorType, quotation|None) 반환. (회사 스코프 없음)""" err_type, quotation = await DB_SESSION_MNG.execute_lambda( @@ -58,9 +94,11 @@ class QuotationService: res.result.SetResult(err_type) return res - # 참여 협력사 수(세션 distinct supplier)를 이 페이지 견적들에 대해 한 방으로 세서 합친다(메인 쿼리 비건드림). + # 참여 협력사 수(세션 distinct supplier)와 대표 상품(세션 item)을 이 페이지 견적들에 대해 + # 각각 한 방으로 모아 합친다(메인 쿼리 비건드림). qt_ids = [r.qt_id for r in rows] counts = {} + item_map = {} if qt_ids: cnt_err, got = await DB_SESSION_MNG.execute_lambda( quotations.DBType(), @@ -69,8 +107,18 @@ class QuotationService: ) if cnt_err == ErrorType.SUCCESS: counts = got + im_err, got_im = await DB_SESSION_MNG.execute_lambda( + quotations.DBType(), + DBWRType.DB_READ.value, + lambda s: self.quotation_crud.item_map(s, qt_ids), + ) + if im_err == ErrorType.SUCCESS: + item_map = got_im for r in rows: r.participation_count = counts.get(r.qt_id, 0) + item = item_map.get(r.qt_id) + if item: + r.item_id, r.item_name = item res.quotations = [QuotationData.model_validate(r) for r in rows] res.total = total @@ -87,23 +135,133 @@ class QuotationService: async def create_quotation(self, user_id: str, data: dict) -> Res_CreateQuotation: res = Res_CreateQuotation() - quotation = quotations(**data, user_id=uuid.UUID(user_id)) + + # 세션 생성용 입력은 quotations 컬럼이 아니므로 분리한다(상품 × 공급사 조합마다 세션 1개). + item_ids = data.pop("item_ids", []) or [] + supplier_ids = data.pop("supplier_ids", []) or [] + card_ids = data.pop("card_ids", []) or [] + + # NOT NULL 컬럼 보정(프론트 미전송 시 서버 디폴트). + if not data.get("version_id"): + data["version_id"] = self.DEFAULT_VERSION_ID + if not data.get("start_time"): + data["start_time"] = GTime.UTC() + if not data.get("number"): + data["number"] = self._gen_number() + if not data.get("status"): + data["status"] = QuotationStatus.CREATED.value + if not data.get("round"): + data["round"] = 1 # ORM 기본값은 flush 시점이라, 세션 스냅샷용으로 미리 확정한다 + # DB 컬럼이 naive 라, tz-aware 로 들어온 시각(프론트 toISOString)을 UTC naive 로 맞춘다. + data["start_time"] = self._naive_utc(data.get("start_time")) + data["end_time"] = self._naive_utc(data.get("end_time")) + + # 세션 목표가 입력(상품 단가 + 견적 세팅 목표 마진율). 읽기 트랜잭션에서 먼저 조회. + prices = {} + if item_ids: + _err, prices = await DB_SESSION_MNG.execute_lambda( + quotations.DBType(), + DBWRType.DB_READ.value, + lambda s: self.quotation_crud.get_item_prices(s, item_ids), + ) + prices = prices if _err == ErrorType.SUCCESS else {} + _err, margin = await DB_SESSION_MNG.execute_lambda( + quotations.DBType(), + DBWRType.DB_READ.value, + lambda s: self.quotation_crud.get_target_margin(s, data["qt_setting_id"]), + ) + margin = margin if _err == ErrorType.SUCCESS else None + + # 선택 협상카드가 있으면 새 버전을 만들어 카드들을 묶고, quotation.version_id 로 연결한다. + # (quotation↔card 는 chats 가 아니라 version → version_nego_cards/version_wild_cards 로 연결.) + version_obj = None + link_rows = [] + if card_ids: + _err, card_types = await DB_SESSION_MNG.execute_lambda( + quotations.DBType(), + DBWRType.DB_READ.value, + lambda s: self.quotation_crud.classify_card_ids(s, card_ids), + ) + card_types = card_types if _err == ErrorType.SUCCESS else {} + new_version_id = uuid.uuid4() + version_obj = versions( + version_id=new_version_id, + user_id=uuid.UUID(user_id), + code=0, + name=(data.get("number") or "견적버전")[:10], + ) + for cid in card_ids: + t = card_types.get(cid) + if t == 1: + link_rows.append(version_nego_cards(version_id=new_version_id, nego_card_id=cid)) + elif t == 2: + link_rows.append(version_wild_cards(version_id=new_version_id, wild_card_id=cid)) + data["version_id"] = new_version_id + + # qt_id 를 미리 발급해 세션 FK(quotation_id)와 묶고, 한 트랜잭션에 함께 insert 한다. + qt_id = uuid.uuid4() + quotation = quotations(**data, qt_id=qt_id, user_id=uuid.UUID(user_id)) + + session_objs = [] + for iid in item_ids: + tp = self._calc_target_price(prices.get(iid), margin) + for sid in supplier_ids: + session_objs.append( + sessions( + session_id=uuid.uuid4(), + quotation_id=qt_id, + item_id=iid, + supplier_id=sid, + qt_number=quotation.number, + qt_round=quotation.round, + qt_type=quotation.type, + target_price=tp, + status=SessionStatus.CREATED.value, + end_time=quotation.end_time, + ) + ) + + # 버전 → (버전-카드 매핑) → 견적 → 세션 순으로 한 트랜잭션에 insert(FK 순서 보장). + ops = [] + if version_obj is not None: + ops.append(lambda s: self.quotation_crud.add_rows(s, [version_obj])) + ops.append(lambda s: self.quotation_crud.add_rows(s, link_rows)) + ops.append(lambda s: self.quotation_crud.add_quotation(s, quotation)) + ops.append(lambda s: self.quotation_crud.add_sessions(s, session_objs)) err_type = await DB_SESSION_MNG.execute_lambda_run( [quotations.DBType()], - [lambda s: self.quotation_crud.add_quotation(s, quotation)], + ops, ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res - # 서버 기본값(created_at/updated_at) 로드 위해 재조회 (응답 shape 은 Res_CreateQuotation 유지). + + # 서버 기본값(created_at/updated_at) 로드 위해 재조회. f_err, fresh = await DB_SESSION_MNG.execute_lambda( quotations.DBType(), DBWRType.DB_READ.value, - lambda s: self.quotation_crud.get_by_id(s, quotation.qt_id), + lambda s: self.quotation_crud.get_by_id(s, qt_id), ) res.quotation = QuotationData.model_validate(fresh if f_err == ErrorType.SUCCESS and fresh is not None else quotation) - # 네고시움 백엔드 비동기 요청은 스텁이므로 row 만 생성한다. - res.async_job = AsyncJob(status="submitted", message="견적 생성 작업 요청됨(스텁)") + + # 생성된 세션 + 각 세션 chat 실행 URL 을 함께 반환(협상리스트에서 바로 진입 가능). + res.sessions = [ + SessionData( + session_id=so.session_id, + qt_id=so.quotation_id, + supplier_id=so.supplier_id, + item_id=so.item_id, + qt_number=so.qt_number, + qt_round=so.qt_round, + qt_type=so.qt_type, + target_price=so.target_price, + status=so.status, + end_time=so.end_time, + url=self._session_chat_url(so.session_id), + ) + for so in session_objs + ] + res.async_job = AsyncJob(status="created", message=f"협상 세션 {len(session_objs)}건 생성") return res async def stop_quotation(self, qt_id: str) -> Res_Quotation: @@ -119,7 +277,7 @@ class QuotationService: # 상태를 '견적마감'으로 변경(실제 DB 업데이트) err_type = await DB_SESSION_MNG.execute_lambda_run( [quotations.DBType()], - [lambda s: self.quotation_crud.update_quotation(s, qt_uuid, {"status": "견적마감"})], + [lambda s: self.quotation_crud.update_quotation(s, qt_uuid, {"status": QuotationStatus.CLOSED.value})], ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) @@ -207,6 +365,7 @@ class QuotationService: reject_reason=r.reject_reason, reject_price=r.reject_price, reject_delivery_type=r.reject_delivery_type, + url=self._session_chat_url(r.session_id), ) for r in rows ] @@ -252,34 +411,34 @@ class QuotationService: res.result.SetResult(err_type) return res + # 견적의 버전(quotation.version_id)에 묶인 카드를 조회한다(version_nego_cards/version_wild_cards). err_type, rows = await DB_SESSION_MNG.execute_lambda( - chats.DBType(), + quotations.DBType(), DBWRType.DB_READ.value, - lambda s: self.quotation_crud.list_used_cards(s, qt_uuid), + lambda s: self.quotation_crud.get_version_cards(s, quotation.version_id), ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res res.qt_id = quotation.qt_id - # rows = [(chat_row, card_id, number, name, script, edit_script, condition, memo), ...]. - # nego/wild 구분은 chats.card_type. condition/memo 는 와일드카드에만 존재. + # rows = [(card_type, card_pk, number, name, script, edit_script, condition, memo), ...]. cards = [] - for chat_row, nc_id, nc_number, nc_name, nc_script, nc_edit, wc_condition, wc_memo in rows: - is_wild = chat_row.card_type == 2 + for card_type, card_pk, number, name, script, edit, condition, memo in rows: + is_wild = card_type == 2 cards.append( QuotationCardData( - session_card_id=chat_row.chat_id, + session_card_id=card_pk, qt_id=quotation.qt_id, - nego_card_id=None if is_wild else nc_id, - wild_card_id=nc_id if is_wild else None, - type=chat_row.card_type if chat_row.card_type is not None else 1, - number=nc_number, - name=nc_name, - script=nc_script, - edit_script=nc_edit, - condition=wc_condition if is_wild else None, - memo=wc_memo if is_wild else None, + nego_card_id=None if is_wild else card_pk, + wild_card_id=card_pk if is_wild else None, + type=card_type, + number=number, + name=name, + script=script, + edit_script=edit, + condition=condition, + memo=memo, ) ) res.cards = cards diff --git a/negodata/front/src/api/generated/model/index.ts b/negodata/front/src/api/generated/model/index.ts index 094606e..92ade6b 100644 --- a/negodata/front/src/api/generated/model/index.ts +++ b/negodata/front/src/api/generated/model/index.ts @@ -66,6 +66,8 @@ export * from './quotationData'; export * from './quotationDataCreatedAt'; export * from './quotationDataEqualBidData'; export * from './quotationDataEqualBidYn'; +export * from './quotationDataItemId'; +export * from './quotationDataItemName'; export * from './quotationDataManagerContactNumber'; export * from './quotationDataManagerEmail'; export * from './quotationDataManagerName'; @@ -108,6 +110,8 @@ export * from './reqCreateQuotationManagerEmail'; export * from './reqCreateQuotationManagerName'; export * from './reqCreateQuotationMemo'; export * from './reqCreateQuotationSetting'; +export * from './reqCreateQuotationStartTime'; +export * from './reqCreateQuotationVersionId'; export * from './reqCreateSupplier'; export * from './reqCreateSupplierCode'; export * from './reqCreateSupplierManagerContactNumber'; diff --git a/negodata/front/src/api/generated/model/quotationData.ts b/negodata/front/src/api/generated/model/quotationData.ts index 352b170..0665b08 100644 --- a/negodata/front/src/api/generated/model/quotationData.ts +++ b/negodata/front/src/api/generated/model/quotationData.ts @@ -13,6 +13,8 @@ import type { QuotationDataPreferredSpId } from './quotationDataPreferredSpId'; import type { QuotationDataPreferredSpName } from './quotationDataPreferredSpName'; import type { QuotationDataEqualBidYn } from './quotationDataEqualBidYn'; import type { QuotationDataEqualBidData } from './quotationDataEqualBidData'; +import type { QuotationDataItemId } from './quotationDataItemId'; +import type { QuotationDataItemName } from './quotationDataItemName'; import type { QuotationDataCreatedAt } from './quotationDataCreatedAt'; import type { QuotationDataUpdatedAt } from './quotationDataUpdatedAt'; @@ -39,6 +41,8 @@ export interface QuotationData { equal_bid_yn?: QuotationDataEqualBidYn; equal_bid_data?: QuotationDataEqualBidData; participation_count?: number; + item_id?: QuotationDataItemId; + item_name?: QuotationDataItemName; created_at?: QuotationDataCreatedAt; updated_at?: QuotationDataUpdatedAt; } diff --git a/negodata/front/src/api/generated/model/quotationDataItemId.ts b/negodata/front/src/api/generated/model/quotationDataItemId.ts new file mode 100644 index 0000000..12418b6 --- /dev/null +++ b/negodata/front/src/api/generated/model/quotationDataItemId.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 QuotationDataItemId = string | null; diff --git a/negodata/front/src/api/generated/model/quotationDataItemName.ts b/negodata/front/src/api/generated/model/quotationDataItemName.ts new file mode 100644 index 0000000..8ad4c45 --- /dev/null +++ b/negodata/front/src/api/generated/model/quotationDataItemName.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 QuotationDataItemName = string | null; diff --git a/negodata/front/src/api/generated/model/reqCreateQuotation.ts b/negodata/front/src/api/generated/model/reqCreateQuotation.ts index ae61c38..bac460b 100644 --- a/negodata/front/src/api/generated/model/reqCreateQuotation.ts +++ b/negodata/front/src/api/generated/model/reqCreateQuotation.ts @@ -4,6 +4,8 @@ * Negodata Api Server * OpenAPI spec version: 0.1.0 */ +import type { ReqCreateQuotationVersionId } from './reqCreateQuotationVersionId'; +import type { ReqCreateQuotationStartTime } from './reqCreateQuotationStartTime'; import type { ReqCreateQuotationManagerName } from './reqCreateQuotationManagerName'; import type { ReqCreateQuotationManagerEmail } from './reqCreateQuotationManagerEmail'; import type { ReqCreateQuotationManagerContactNumber } from './reqCreateQuotationManagerContactNumber'; @@ -11,16 +13,19 @@ import type { ReqCreateQuotationMemo } from './reqCreateQuotationMemo'; export interface ReqCreateQuotation { qt_setting_id: string; - version_id: string; + version_id?: ReqCreateQuotationVersionId; name?: string; number?: string; type?: number; status?: number; - start_time: string; + start_time?: ReqCreateQuotationStartTime; end_time: string; round?: number; manager_name?: ReqCreateQuotationManagerName; manager_email?: ReqCreateQuotationManagerEmail; manager_contact_number?: ReqCreateQuotationManagerContactNumber; memo?: ReqCreateQuotationMemo; + item_ids?: string[]; + supplier_ids?: string[]; + card_ids?: string[]; } diff --git a/negodata/front/src/api/generated/model/reqCreateQuotationStartTime.ts b/negodata/front/src/api/generated/model/reqCreateQuotationStartTime.ts new file mode 100644 index 0000000..9485a9c --- /dev/null +++ b/negodata/front/src/api/generated/model/reqCreateQuotationStartTime.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 ReqCreateQuotationStartTime = string | null; diff --git a/negodata/front/src/api/generated/model/reqCreateQuotationVersionId.ts b/negodata/front/src/api/generated/model/reqCreateQuotationVersionId.ts new file mode 100644 index 0000000..d0d73c8 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqCreateQuotationVersionId.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 ReqCreateQuotationVersionId = string | null; diff --git a/negodata/front/src/api/generated/model/resCreateQuotation.ts b/negodata/front/src/api/generated/model/resCreateQuotation.ts index 0ee05cb..76ba712 100644 --- a/negodata/front/src/api/generated/model/resCreateQuotation.ts +++ b/negodata/front/src/api/generated/model/resCreateQuotation.ts @@ -7,11 +7,13 @@ import type { ErrorInfo } from './errorInfo'; import type { ResCreateQuotationMsg } from './resCreateQuotationMsg'; import type { ResCreateQuotationQuotation } from './resCreateQuotationQuotation'; +import type { SessionData } from './sessionData'; import type { ResCreateQuotationAsyncJob } from './resCreateQuotationAsyncJob'; export interface ResCreateQuotation { result?: ErrorInfo; msg?: ResCreateQuotationMsg; quotation?: ResCreateQuotationQuotation; + sessions?: SessionData[]; async_job?: ResCreateQuotationAsyncJob; } diff --git a/negodata/front/src/api/generated/model/sessionData.ts b/negodata/front/src/api/generated/model/sessionData.ts index 2cea16b..04b2986 100644 --- a/negodata/front/src/api/generated/model/sessionData.ts +++ b/negodata/front/src/api/generated/model/sessionData.ts @@ -26,4 +26,5 @@ export interface SessionData { reject_reason?: SessionDataRejectReason; reject_price?: SessionDataRejectPrice; reject_delivery_type?: SessionDataRejectDeliveryType; + url?: string; } diff --git a/negodata/front/src/features/cards/components/CardTable.tsx b/negodata/front/src/features/cards/components/CardTable.tsx index 82766bd..70996b5 100644 --- a/negodata/front/src/features/cards/components/CardTable.tsx +++ b/negodata/front/src/features/cards/components/CardTable.tsx @@ -1,18 +1,21 @@ +import type { ReactNode } from 'react'; import { DataTable } from '@/components/ui/data-table'; import type { NegotiationCard } from '../types'; type CardTableProps = { data: NegotiationCard[]; onEdit: (card: NegotiationCard) => void; + footer?: ReactNode; }; -export function CardTable({ data, onEdit }: CardTableProps) { +export function CardTable({ data, onEdit, footer }: CardTableProps) { return ( card.id} onRowClick={onEdit} empty="데이터가 없습니다." + footer={footer} columns={[ { header: '구분', diff --git a/negodata/front/src/features/quotations/components/CreateQuotationWizard.tsx b/negodata/front/src/features/quotations/components/CreateQuotationWizard.tsx index 3264857..6a56739 100644 --- a/negodata/front/src/features/quotations/components/CreateQuotationWizard.tsx +++ b/negodata/front/src/features/quotations/components/CreateQuotationWizard.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { X, PlusSquare, ArrowRight } from 'lucide-react'; +import { X, PlusSquare, ArrowRight, Loader2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Typography } from '@/components/ui/typography'; import { Input } from '@/components/ui/input'; @@ -13,7 +13,7 @@ type CreateQuotationWizardProps = { partners: Partner[]; cards: NegotiationCard[]; quotationSettings: QuotationSetting[]; - onCreate: (input: CreateQuotationInput) => boolean; + onCreate: (input: CreateQuotationInput) => boolean | Promise; onClose: () => void; }; @@ -34,6 +34,7 @@ export function CreateQuotationWizard({ const [dueDate, setDueDate] = useState('2026-06-15T18:00'); const [settingId, setSettingId] = useState(quotationSettings[0]?.qt_setting_id ?? ''); const [selectedCardIds, setSelectedCardIds] = useState([]); + const [submitting, setSubmitting] = useState(false); if (!open) return null; @@ -46,28 +47,44 @@ export function CreateQuotationWizard({ prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id], ); - const handleSubmit = () => { - const ok = onCreate({ - title, - type, - productId, - partnerIds: selectedPartnerIds, - dueDate, - settingId, - cardIds: selectedCardIds, - }); - if (ok) onClose(); + const handleSubmit = async () => { + if (submitting) return; + setSubmitting(true); + try { + // 서버가 견적+세션 생성을 끝내고 응답할 때까지 기다린 뒤에 완료(닫기) 처리한다. + const ok = await onCreate({ + title, + type, + productId, + partnerIds: selectedPartnerIds, + dueDate, + settingId, + cardIds: selectedCardIds, + }); + if (ok) onClose(); + } finally { + setSubmitting(false); + } }; return (
+ {submitting && ( +
+
+ + 협상견적 생성 중… + 견적 · 협상 세션 등록 중 +
+
+ )}
{/* Header */}
- 신규 견적 발의 (단계 {step}/3) + 신규 협상견적 등록 (단계 {step}/3)
@@ -263,8 +289,8 @@ export function CreateQuotationWizard({ 다음 단계로 ) : ( - )}
diff --git a/negodata/front/src/features/quotations/components/QuotationDetailDrawer.tsx b/negodata/front/src/features/quotations/components/QuotationDetailDrawer.tsx index 3826b53..82d0145 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailDrawer.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailDrawer.tsx @@ -8,7 +8,10 @@ import { Layers, Sparkles, Package, + ExternalLink, + Copy, } from 'lucide-react'; +import { showToast } from '@/lib/notify'; import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table'; import { Typography } from '@/components/ui/typography'; import { Input } from '@/components/ui/input'; @@ -390,6 +393,7 @@ export function QuotationDetailDrawer({ 세션 ID 협력사 + 협상 URL 상품 협상상태 목표가 @@ -404,7 +408,7 @@ export function QuotationDetailDrawer({ {sessionViews.length === 0 && ( - + 참여 중인 협상 세션이 없습니다. (리스트가 비어 있습니다) @@ -424,6 +428,33 @@ export function QuotationDetailDrawer({
+ + {sess.url ? ( +
+ + 세션 열기 + + +
+ ) : ( + - + )} +
{sess.item_name} void; + footer?: ReactNode; }; const statusBadgeClass = (status?: string | null) => { @@ -23,18 +25,20 @@ const statusBadgeClass = (status?: string | null) => { } }; -export function QuotationTable({ data, products, onOpenDetail }: QuotationTableProps) { +export function QuotationTable({ data, products, onOpenDetail, footer }: QuotationTableProps) { return ( est.id ?? ''} onRowClick={(est) => onOpenDetail(est.id ?? '')} empty="진행 중인 전자 견적 및 자동 협상 계약 내역이 존재하지 않습니다." + footer={footer} columns={[ { header: '견적건명', cell: (est) => { const product = products.find((p) => p.id === est.productId); + const productName = product?.name ?? est.productName; // 목록에 없으면 서버 조인 상품명으로 폴백 return (
@@ -42,7 +46,7 @@ export function QuotationTable({ data, products, onOpenDetail }: QuotationTableP - 대상 상품: {product ? product.name : '확인 불가'} (₩{(product?.price ?? 0).toLocaleString()}) + 대상 상품: {productName ?? '확인 불가'} (₩{(product?.price ?? 0).toLocaleString()})
); diff --git a/negodata/front/src/features/quotations/hooks/useQuotations.ts b/negodata/front/src/features/quotations/hooks/useQuotations.ts index cf43346..4c111d8 100644 --- a/negodata/front/src/features/quotations/hooks/useQuotations.ts +++ b/negodata/front/src/features/quotations/hooks/useQuotations.ts @@ -10,7 +10,13 @@ import { useDeleteSetting, getListSettingsQueryKey, } from '@/api/generated/quotation-setting/quotation-setting'; -import { useListQuotations } from '@/api/generated/quotation/quotation'; +import { + useListQuotations, + useCreateQuotation, + useStopQuotation, + getListQuotationsQueryKey, +} from '@/api/generated/quotation/quotation'; +import type { ReqCreateQuotation } from '@/api/generated/model/reqCreateQuotation'; import type { ItemData } from '@/api/generated/model/itemData'; import type { SupplierData } from '@/api/generated/model/supplierData'; import type { QuotationSettingData } from '@/api/generated/model/quotationSettingData'; @@ -18,7 +24,7 @@ import type { QuotationData } from '@/api/generated/model/quotationData'; import type { CardData } from '@/api/generated/model/cardData'; import { showToast } from '@/lib/notify'; import { confirm } from '@/lib/confirm'; -import type { Estimate, ChatSession, Product } from '@/types'; +import type { Estimate } from '@/types'; import { unwrap, mapItem, mapSupplier, mapSetting, mapQuotation } from '../types'; export type CreateQuotationInput = { @@ -49,6 +55,11 @@ export function useQuotations() { const quotationsQuery = useListQuotations(undefined); const createSettingMutation = useCreateSetting(); const deleteSettingMutation = useDeleteSetting(); + const createQuotationMutation = useCreateQuotation(); + const stopQuotationMutation = useStopQuotation(); + + const invalidateQuotations = () => + queryClient.invalidateQueries({ queryKey: getListQuotationsQueryKey(undefined) }); const products = (unwrap<{ items?: ItemData[] }>(itemsQuery.data)?.items ?? []).map(mapItem); const partners = (unwrap<{ suppliers?: SupplierData[] }>(suppliersQuery.data)?.suppliers ?? []).map(mapSupplier); @@ -64,15 +75,27 @@ export function useQuotations() { if (qs) setQuotations(qs.map(mapQuotation)); }, [quotationsQuery.data]); - // 협상카드 카탈로그는 서버(orval)에서 읽어 단계 3/3 카드 선택지로 쓴다. 채팅은 아직 미연동. + // 협상카드 카탈로그는 서버(orval)에서 읽어 단계 3/3 카드 선택지로 쓴다. const cards = (unwrap<{ cards?: CardData[] }>(cardsQuery.data)?.cards ?? []).map(mapCardData); - const [chatSessions, setChatSessions] = useState>({}); - // 협상 강제중단 → '협상보류'. + // 협상 강제중단 → 서버 stop_quotation 호출(상태 '견적마감'으로 영속). 성공 시 목록 무효화로 서버값 재동기화. const stopNegotiation = async (id: string, name: string) => { if (!(await confirm({ title: '협상 강제중단', description: `현재 입찰 중인 [${name}] 단가 협상 절차를 즉시 조기 중단(강제종료)하시겠습니까?`, confirmText: '중단', destructive: true }))) return; - setQuotations((prev) => prev.map((e) => (e.id === id ? { ...e, status: '협상보류' } : e))); - showToast("해당 협상이 관리자에 의하여 '협상보류' 상태로 지정되었습니다.", 'info'); + // 낙관적 갱신 — 서버가 CLOSED 로 바꾸므로 화면도 '견적마감'으로 선반영. + setQuotations((prev) => prev.map((e) => (e.id === id ? { ...e, status: '견적마감' } : e))); + stopQuotationMutation.mutate( + { qtId: id }, + { + onSuccess: () => { + invalidateQuotations(); + showToast(`[${name}] 협상이 중단되어 '견적마감' 처리되었습니다.`, 'info'); + }, + onError: () => { + invalidateQuotations(); // 실패 시 서버 진짜값으로 롤백 + showToast('견적 중단에 실패했습니다. 잠시 후 다시 시도해 주세요.', 'error'); + }, + }, + ); }; const invalidateSettings = () => @@ -120,51 +143,48 @@ export function useQuotations() { ); }; - // 신규 견적 발의 — 견적 레코드 + 협력사별 채팅 세션을 생성. 검증 실패 시 toast 후 false. - const createQuotation = (input: CreateQuotationInput): boolean => { + // 신규 협상견적 등록 — 서버에 견적 + 협력사별 협상 세션(상품×공급사)을 생성한다. + // 서버 응답(실제 qt_id)을 기다린 뒤에야 완료 처리한다. 성공 시 새 qt_id 반환, 실패/검증오류 시 null. + const createQuotation = async (input: CreateQuotationInput): Promise => { if (!input.title.trim()) { showToast('견적 건명을 올바르게 작성해 주세요.', 'error'); - return false; + return null; } if (!input.productId) { showToast('협상 대상 품목을 지정하지 않았습니다.', 'error'); - return false; + return null; } if (input.partnerIds.length === 0) { showToast('최소 한 곳 이상의 벤더사(참여 협력사)를 선정해 주세요.', 'error'); - return false; + return null; } - const targetProduct = products.find((p) => p.id === input.productId); - const estId = `est-${Date.now()}`; - const newEstNumber = `EST-202606-${Math.floor(1000 + Math.random() * 9000)}`; - - const newQuotationRecord: Estimate = { - id: estId, - title: input.title, - number: newEstNumber, - type: input.type, - round: 1, - status: '견적생성', - dueDate: input.dueDate.replace('T', ' '), - participationCount: input.partnerIds.length, - productId: input.productId, - partnerIds: input.partnerIds, - settingApplied: input.settingId, - usedCardIds: input.cardIds, - winnerPartnerId: undefined, - finalPrice: undefined, - isEqualPrice: false, + const payload: ReqCreateQuotation = { + qt_setting_id: input.settingId, + name: input.title, + type: input.type === 'RE_ESTIMATE' ? 2 : 1, + end_time: new Date(input.dueDate).toISOString(), + item_ids: [input.productId], + supplier_ids: input.partnerIds, + card_ids: input.cardIds, }; - const generatedSessions: ChatSession[] = input.partnerIds.map((pId) => - buildInitialSession(pId, estId, partners, targetProduct), - ); - - setChatSessions((prev) => ({ ...prev, [estId]: generatedSessions })); - setQuotations((prev) => [newQuotationRecord, ...prev]); - showToast(`신규 견적 제안서[${input.title}]가 발행되었습니다.`, 'info'); - return true; + try { + const res = await createQuotationMutation.mutateAsync({ data: payload }); + const qtId = res?.quotation?.qt_id ?? null; + // 서버가 result.success=false 거나 qt_id 가 없으면 실패로 처리(HTTP 200 이어도). 모달은 그대로 유지. + if (!res?.result?.success || !qtId) { + showToast(`견적 생성 실패: ${res?.result?.desc ?? '서버 오류'}`, 'error'); + return null; + } + // 목록 재조회가 끝나야 새 견적이 정본 목록에 들어온다(상세 자동오픈이 그 행을 찾을 수 있게). + await invalidateQuotations(); + showToast(`협상견적[${input.title}] 생성 완료 — 협상 세션 ${res?.sessions?.length ?? 0}건.`, 'success'); + return qtId; + } catch { + showToast('견적 생성에 실패했습니다. 입력값을 확인해 주세요.', 'error'); + return null; + } }; return { @@ -173,62 +193,9 @@ export function useQuotations() { cards, quotations, quotationSettings, - chatSessions, stopNegotiation, addSetting, deleteSetting, createQuotation, }; } - -// 참여 협력사 1곳에 대한 초기 채팅 세션(시스템/봇/협력사 메시지) 생성. -function buildInitialSession( - pId: string, - estId: string, - partners: ReturnType[], - targetProduct: Product | undefined, -): ChatSession { - const partnerObj = partners.find((part) => part.id === pId); - const partnerName = partnerObj?.name ?? '미지정 파트너'; - const initialPartnerBid = Math.round((targetProduct?.price || 1000000) * (0.95 + Math.random() * 0.1)); - const targetNegoPrice = Math.round((targetProduct?.price || 1000000) * 0.9); - - return { - id: pId, - partnerName, - status: 'NEGOTIATING', - currentBid: initialPartnerBid, - bidTime: '2026-06-11 01:10', - messages: [ - { - id: `msg-${estId}-sys`, - sender: 'SYSTEM', - timestamp: '2026-06-11 01:00', - content: `사전에 조율된 [${targetProduct?.name}] 입찰 참여 세션이 개시되었습니다.`, - }, - { - id: `msg-${estId}-bot`, - sender: 'BOT', - timestamp: '2026-06-11 01:02', - content: '안녕하세요, 구매 대행 봇입니다.', - editorScript: [ - { - type: 'paragraph', - children: [ - { text: `${partnerName} 조달담당자님 안녕하십니까.\n` }, - { text: `이번에 진행되는 ${targetProduct?.name} 품목에 대해 당사 타깃가는 ` }, - { text: `${targetNegoPrice.toLocaleString('ko-KR')}원`, bold: true, color: 'primary' }, - { text: ' 수준입니다. 상호 이익 극대화를 위해 적합 단가를 제안해주시면 검토 후 즉시 타결 또는 와일드카드 혜택이 적용됩니다.' }, - ], - }, - ], - }, - { - id: `msg-${estId}-part`, - sender: 'PARTNER', - timestamp: '2026-06-11 01:05', - content: `안녕하세요. 본 제품은 제조 원가 고사양으로 인해 타사 대비 높은 정밀도가 들어갑니다. 우선 1차 투찰로 ${initialPartnerBid.toLocaleString()}원을 입력 제출합니다.`, - }, - ], - }; -} diff --git a/negodata/front/src/features/quotations/types.ts b/negodata/front/src/features/quotations/types.ts index 379ac00..ef27df8 100644 --- a/negodata/front/src/features/quotations/types.ts +++ b/negodata/front/src/features/quotations/types.ts @@ -69,6 +69,8 @@ export function mapQuotation(q: QuotationData): Estimate { type: normalizeQuotationType(q.type), status: normalizeQuotationStatus(q.status) || String(q.status ?? ''), settingApplied: q.qt_setting_id, // 드로어 견적세팅 카드가 qt_setting_id 로 매칭 + productId: q.item_id ?? undefined, // 서버 목록 조인(세션 대표 상품). products 목록과 id 매칭용 + productName: q.item_name ?? undefined, // products 목록에 없을 때 표기 폴백 dueDate: formatDueDate(q.end_time), participationCount: q.participation_count ?? 0, winnerPartnerId: q.preferred_sp_id ?? q.preferred_sp_name ?? null, @@ -156,6 +158,7 @@ export type SessionView = { reject_price: number | null; reject_delivery_type: string | null; end_time: string; + url: string; // 세션 chat 실행 URL(공급사 협상 프론트) }; export type QuotationCardView = { @@ -249,6 +252,7 @@ export function buildSessions( reject_price, reject_delivery_type, end_time: est.end_time || est.dueDate || '미지정', + url: '', }; }); } @@ -289,6 +293,7 @@ export function mapServerSessionView(sd: SessionData, partners: Partner[], produ ? DELIVERY_TYPE_LABEL[sd.reject_delivery_type] || String(sd.reject_delivery_type) : null, end_time: fmtDateTime(sd.end_time), + url: sd.url || '', }; } diff --git a/negodata/front/src/lib/useClientPagination.ts b/negodata/front/src/lib/useClientPagination.ts new file mode 100644 index 0000000..32b7cf1 --- /dev/null +++ b/negodata/front/src/lib/useClientPagination.ts @@ -0,0 +1,22 @@ +import { useEffect, useState } from 'react'; + +// 클라이언트사이드 페이지네이션 단일 출처. +// 서버가 전량(또는 큰 페이지)을 한 번에 주고, 검색·필터를 프론트에서 거른 뒤 +// 그 결과 배열을 화면용으로 잘라 보여줄 때 쓴다(견적·카드 목록). +// 서버 page/size 를 직접 보내는 useServerList 와 달리, 이 훅은 이미 받은 배열을 slice 만 한다. +export function useClientPagination(items: T[], pageSize = 10) { + const [page, setPage] = useState(1); + + const totalCount = items.length; + const totalPages = Math.max(1, Math.ceil(totalCount / pageSize)); + + // 필터·삭제로 결과가 줄어 현재 페이지가 범위를 벗어나면 마지막 페이지로 당긴다. + useEffect(() => { + if (page > totalPages) setPage(totalPages); + }, [page, totalPages]); + + const safePage = Math.min(page, totalPages); + const pageItems = items.slice((safePage - 1) * pageSize, safePage * pageSize); + + return { page: safePage, setPage, pageSize, totalPages, totalCount, pageItems }; +} diff --git a/negodata/front/src/pages/cards.tsx b/negodata/front/src/pages/cards.tsx index 1bad07d..27ac37c 100644 --- a/negodata/front/src/pages/cards.tsx +++ b/negodata/front/src/pages/cards.tsx @@ -4,7 +4,9 @@ import { showToast } from '@/lib/notify'; import { confirm } from '@/lib/confirm'; import { PageContainer } from '@/components/layout/PageContainer'; import { SearchInput } from '@/components/layout/PageToolbar'; +import { TablePagination } from '@/components/ui/table-pagination'; import { Typography } from '@/components/ui/typography'; +import { useClientPagination } from '@/lib/useClientPagination'; import { useCards } from '@/features/cards/hooks/useCards'; import { useCardFilters } from '@/features/cards/hooks/useCardFilters'; import { CardTable } from '@/features/cards/components/CardTable'; @@ -14,6 +16,7 @@ import type { CardTab, NegotiationCard } from '@/features/cards/types'; export default function CardsPage() { const { cards, createCard, updateCard, deleteCard } = useCards(); const { search, setSearch, activeTab, setActiveTab, filtered, counts } = useCardFilters(cards); + const { page, setPage, pageSize, totalPages, totalCount, pageItems } = useClientPagination(filtered); // 오버레이(폼)를 쿼리스트링으로 → 딥링크·뒤로가기·새로고침 지원. // ?edit= 직접 접근 시 데이터 로드 후 수정 폼이 자동으로 열린다. @@ -93,7 +96,21 @@ export default function CardsPage() { placeholder="전체 카드이름, 카드번호, 코드 및 핵심멘트 검색..." /> - + + } + /> {isFormOpen && ( 로 직접 접근하면 데이터 로드 후 상세가 자동으로 열린다. @@ -56,7 +59,7 @@ export default function QuotationPage() { className="flex items-center gap-2 px-4 py-2.5 bg-primary text-primary-foreground text-xs font-bold rounded hover:opacity-95 cursor-pointer transition-colors animate-pulse" > - 신규 협상견적 발의 + 신규 협상견적 등록 } @@ -97,9 +100,20 @@ export default function QuotationPage() { overlay.open('detail', id)} + footer={ + + } /> {activeQuotation && ( @@ -121,7 +135,11 @@ export default function QuotationPage() { partners={partners} cards={cards} quotationSettings={quotationSettings} - onCreate={createQuotation} + onCreate={async (input) => { + const qtId = await createQuotation(input); + if (qtId) overlay.open('detail', qtId); // 생성 완료된 실제 qt_id 로 상세(협상현황) 자동 오픈 + return !!qtId; + }} onClose={overlay.close} /> )} diff --git a/negodata/front/src/types.ts b/negodata/front/src/types.ts index d83cc62..699ccac 100644 --- a/negodata/front/src/types.ts +++ b/negodata/front/src/types.ts @@ -263,6 +263,7 @@ export type Estimate = Partial & { dueDate?: string; // end_time title?: string; // mapped to name in UI productId?: string; // mapped to association + productName?: string; // 서버 목록 조인 상품명(products 목록에 없을 때 폴백) partnerIds?: string[]; // mapped B2B suppliers participationCount?: number; winnerPartnerId?: string | null;