diff --git a/agent/negotiation/chat/infra/__init__.py b/agent/negotiation/chat/infra/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/negotiation/chat/infra/repository/__init__.py b/agent/negotiation/chat/infra/repository/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/negotiation/chat/infra/repository/nego_context_crud.py b/agent/negotiation/chat/infra/repository/nego_context_crud.py new file mode 100644 index 0000000..39402f8 --- /dev/null +++ b/agent/negotiation/chat/infra/repository/nego_context_crud.py @@ -0,0 +1,135 @@ +"""협상 컨텍스트 CRUD — backend 소유 스키마 read-only 조회 (backend crud 패턴 준용). + +backend/crud/chat_crud.py(IChatCRUD/ChatCRUD)와 같은 형식: ABC 인터페이스 + 구현 클래스, +cdb(AsyncSession) 주입, (ErrorType, data) 반환. 트랜잭션/세션 경계는 호출부 +(NegotiationContextLoader)가 DB_SESSION_MNG.execute_lambda 로 관리한다. + +스키마 소유권: negotiation/partner/quotation 스키마는 backend 소유(01-schema.sql) — +여기서는 read-only 로만 접근한다. ORM 모델 중복 정의를 피하려고 sqlalchemy 경량 +table()/column() 구성을 쓴다(agent DB 매니저는 text() 미지원 — select 구성만 가능). +""" + +from abc import ABC, abstractmethod +from typing import Optional, Tuple + +from sqlalchemy import column, distinct, func, select, table +from sqlalchemy.ext.asyncio import AsyncSession + +from common.database.db_session_manager import DB_SESSION_MNG +from common.enums import ErrorType +from common.logger import LOG + +_SESSIONS = table( + "sessions", + column("session_id"), column("quotation_id"), column("item_id"), column("supplier_id"), + column("qt_type"), column("target_price"), column("anchoring_price"), + column("deleted"), + schema="negotiation", +) +_ITEMS = table("items", column("item_id"), column("price"), column("deleted"), schema="partner") +_SUPPLIERS = table("suppliers", column("supplier_id"), column("total_revenue"), column("deleted"), schema="partner") +_QUOTATIONS = table("quotations", column("qt_id"), column("supplier_type"), column("deleted"), schema="quotation") + + +class INegoContextCRUD(ABC): + @abstractmethod + async def get_session_row(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, Optional[tuple]]: + """세션 행 (qt_type, target_price, anchoring_price, item_id, quotation_id, supplier_id). 없으면 None.""" + pass + + @abstractmethod + async def get_item_price(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, int]: + """품목 기준가(items.price). 없으면 0.""" + pass + + @abstractmethod + async def get_supplier_total_revenue(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, float]: + """협력사 총매출액(suppliers.total_revenue — KTC 미러). 없으면 0.0.""" + pass + + @abstractmethod + async def get_quotation_supplier_type(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, Optional[int]]: + """견적의 협력사 유형(quotations.supplier_type: 0=none/1=유통/2=제조/3=총판). 미지정 시 None.""" + pass + + @abstractmethod + async def count_item_suppliers(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, int]: + """상품에 연결된 협력사 수(협상 세션 이력 기준 distinct supplier).""" + pass + + +class NegoContextCRUD(INegoContextCRUD): + async def get_session_row(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, Optional[tuple]]: + try: + query = ( + select(_SESSIONS.c.qt_type, _SESSIONS.c.target_price, _SESSIONS.c.anchoring_price, + _SESSIONS.c.item_id, _SESSIONS.c.quotation_id, _SESSIONS.c.supplier_id) + .where(_SESSIONS.c.session_id == session_id, _SESSIONS.c.deleted == False) # noqa: E712 + .limit(1) + ) + err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_session_row failed.", raise_error=False) + if err_type != ErrorType.SUCCESS: + return err_type, None + return ErrorType.SUCCESS, (rows[0] if rows else None) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, None + + async def get_item_price(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, int]: + try: + query = ( + select(_ITEMS.c.price) + .where(_ITEMS.c.item_id == item_id, _ITEMS.c.deleted == False) # noqa: E712 + .limit(1) + ) + err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_item_price failed.", raise_error=False) + if err_type != ErrorType.SUCCESS or not rows or not rows[0]: + return err_type, 0 + return ErrorType.SUCCESS, int(rows[0]) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, 0 + + async def get_supplier_total_revenue(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, float]: + try: + query = ( + select(_SUPPLIERS.c.total_revenue) + .where(_SUPPLIERS.c.supplier_id == supplier_id, _SUPPLIERS.c.deleted == False) # noqa: E712 + .limit(1) + ) + err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_supplier_total_revenue failed.", raise_error=False) + if err_type != ErrorType.SUCCESS or not rows or not rows[0]: + return err_type, 0.0 + return ErrorType.SUCCESS, float(rows[0]) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, 0.0 + + async def get_quotation_supplier_type(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, Optional[int]]: + try: + query = ( + select(_QUOTATIONS.c.supplier_type) + .where(_QUOTATIONS.c.qt_id == quotation_id, _QUOTATIONS.c.deleted == False) # noqa: E712 + .limit(1) + ) + err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_quotation_supplier_type failed.", raise_error=False) + if err_type != ErrorType.SUCCESS or not rows or rows[0] is None: + return err_type, None + return ErrorType.SUCCESS, int(rows[0]) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, None + + async def count_item_suppliers(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, int]: + try: + query = ( + select(func.count(distinct(_SESSIONS.c.supplier_id))) + .where(_SESSIONS.c.item_id == item_id, _SESSIONS.c.deleted == False) # noqa: E712 + ) + err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "count_item_suppliers failed.", raise_error=False) + if err_type != ErrorType.SUCCESS or not rows: + return err_type, 0 + return ErrorType.SUCCESS, int(rows[0] or 0) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, 0 diff --git a/agent/negotiation/chat/service/chat_engine.py b/agent/negotiation/chat/service/chat_engine.py index 8a7e1e8..af362d3 100644 --- a/agent/negotiation/chat/service/chat_engine.py +++ b/agent/negotiation/chat/service/chat_engine.py @@ -89,9 +89,16 @@ class ChatEngine: if price is None: return self._error(session, "가격을 숫자로 입력해 주세요.") session.context["input_price"] = price + # 협력사 첫 제시가 — 가격 수용률(첫 제시가 대비 양보율) 동적 계산의 기준값. + session.context.setdefault("first_offer_price", price) session.context["round"] = session.context.get("round", 0) + 1 nxt = self._default_next(node) elif mode in _CHOICE_MODES: + # 와일드카드 1% 인하 제안을 수락("예")하면 합의가를 제안가(offer_1pct)로 확정한다. + # (멘트에만 쓰이던 offer_1pct 가 input_price 에 반영되지 않아, 요약/입찰가가 + # 직전 제시가로 잡히던 버그 수정 — 수락 시 실제 합의가는 인하가다.) + if session.step == "wild_card_1pct" and user_input == "예" and session.context.get("offer_1pct"): + session.context["input_price"] = float(session.context["offer_1pct"]) nxt = self._choice_next(node, user_input, session) else: nxt = self._default_next(node) diff --git a/agent/negotiation/chat/service/chat_session_repository.py b/agent/negotiation/chat/service/chat_session_repository.py index 5f1fc22..78923ec 100644 --- a/agent/negotiation/chat/service/chat_session_repository.py +++ b/agent/negotiation/chat/service/chat_session_repository.py @@ -2,9 +2,11 @@ 인메모리 대신 learning.chat_sessions 에 진행 상태를 저장 → 서버 재시작/멀티워커 안전. company_id 스코프. ChatSession(dataclass) ↔ row 직렬화. +인터페이스(IChatSessionRepository) + 구현 형식 — backend crud 패턴 준용(테스트 더블 주입 가능). """ import uuid +from abc import ABC, abstractmethod from typing import Optional from sqlalchemy import select @@ -18,7 +20,19 @@ from common.utils.gtime import GTime from negotiation.chat.service.chat_engine import ChatSession -class ChatSessionRepository: +class IChatSessionRepository(ABC): + @abstractmethod + async def get(self, session_id: Optional[str]) -> Optional[ChatSession]: + """세션 조회(자사 company_id 스코프). 없으면 None.""" + pass + + @abstractmethod + async def save(self, session: ChatSession) -> ErrorType: + """진행 상태 upsert(재시작/멀티워커 안전).""" + pass + + +class ChatSessionRepository(IChatSessionRepository): def __init__(self, company_id: str): self.company_id = company_id diff --git a/agent/negotiation/chat/service/negotiation_context_loader.py b/agent/negotiation/chat/service/negotiation_context_loader.py new file mode 100644 index 0000000..ccb6177 --- /dev/null +++ b/agent/negotiation/chat/service/negotiation_context_loader.py @@ -0,0 +1,102 @@ +"""NegotiationContextLoader — 협상 시작 컨텍스트를 DB 에서 1회 조회 (Req_Chat 슬림화). + +backend 가 요청마다 실어 보내던 협상 컨텍스트(rq_type/목표가/앵커링가/품목가/매출액/유통코드/ +파트너 유형)를 세션 시작 시 agent 가 직접 조회한다. session_id 는 backend 와 공유하는 +negotiation.sessions.session_id. 행이 없으면(데모/테스트 직접 호출) None 을 반환하고 +호출부(ChatService)가 기본값으로 폴백한다. + +DB 쿼리는 INegoContextCRUD(negotiation/chat/infra/repository/nego_context_crud.py)에 위임 +— backend crud 패턴 준용(인터페이스 + 함수 호출). 여기는 판정 로직(rq_type·앵커 폴백· +코드 매핑)과 세션 경계(execute_lambda)만 담당한다. + +가격 수용률은 여기서 다루지 않는다 — 세션 내 라운드별 제시가로 매 턴 동적 계산(ChatService). +""" + +import uuid +from dataclasses import dataclass +from typing import Optional + +from common.database.db_session_manager import DB_SESSION_MNG +from common.enums import DBType, DBWRType, ErrorType +from common.logger import LOG +from negotiation.chat.infra.repository.nego_context_crud import INegoContextCRUD, NegoContextCRUD +from negotiation.qtable.domain.model.snapshot import PartnerType + +# 1:1 견적유형 → 재협상 스크립트. QuotationType: 1=renego, 3=new_nego (2=requote, 4=new_quote 는 1:N 재견적). +_ONE_TO_ONE_QT_TYPES = (1, 3) + +# 유통 코드: quotations.supplier_type(1=distribution 유통, 2=manufacture 제조, 3=sole_agency 총판) +# → 테넌트 code_map 키(A/B/C). 제조→A, 총판→B, 유통→C (0=none/NULL 은 미지정 → 호출부 기본값). +_SUPPLIER_TYPE_TO_CODE = {2: "A", 3: "B", 1: "C"} + + +@dataclass(frozen=True) +class NegotiationDbContext: + """세션 시작 시 DB 에서 확정되는 협상 컨텍스트 (라운드 진행 중 불변).""" + + rq_type: str # 재협상(1:1) | 재견적(1:N) — sessions.qt_type 으로 판별 + target_price: int # 목표 매입가(원) — sessions.target_price + anchor_price: int # 앵커링가 — sessions.anchoring_price(생성 시 박제). 없으면 target(무할인 폴백) + item_price: int # 기존 공급가(품목 기준가, items.price) — 인하율 멘트용. 없으면 0 + partner_type: PartnerType # 상품에 연결된 협력사 수(distinct supplier) → NONE/SINGLE/MULTIPLE + revenue_amount: float # 매출액(원) — suppliers.total_revenue(KTC 미러). 없으면 0 + distribution_code: Optional[str] # 유통 코드(A/B/C) — quotations.supplier_type 매핑. 미지정 시 None + + +class NegotiationContextLoader: + def __init__(self, crud: Optional[INegoContextCRUD] = None): + self.crud: INegoContextCRUD = crud or NegoContextCRUD() + + async def load(self, session_id: Optional[str]) -> Optional[NegotiationDbContext]: + """session_id 로 협상 컨텍스트 조회. 행이 없거나 조회 실패 시 None(호출부 기본값 폴백).""" + if not session_id: + return None + try: + sid = uuid.UUID(session_id) + except ValueError: + return None # 데모/테스트의 비-UUID 세션 키 + + async def _load(s) -> Optional[NegotiationDbContext]: + err, row = await self.crud.get_session_row(s, sid) + if err != ErrorType.SUCCESS or row is None: + return None + qt_type, target_price, anchoring_price, item_id, quotation_id, supplier_id = row + target = int(target_price or 0) + + # 앵커링가: 세션 생성 시 박제된 값(anchoring_price)을 그대로 사용 — 협상 중 불변. + # 박제가 없으면(데이터 이상) 무할인 폴백 anchor=target + WARN — 앵커링 v1.2 정책상 + # 앵커를 재계산하지 않으며, 해당 세션은 앵커링 집계에서 자동 제외된다. + anchor = int(anchoring_price or 0) + if anchor <= 0: + LOG.w(f"[NegotiationContextLoader] 앵커가 박제 없음 session_id={session_id} — 무할인 폴백(anchor=target)") + anchor = target + + # 매출액: 협력사 총매출(KTC total_revenue 미러). 미기재 시 0 → 호출부 기본값. + _, revenue_amount = await self.crud.get_supplier_total_revenue(s, supplier_id) + + # 유통 코드: 견적의 협력사 유형(supplier_type) 매핑. 미지정 시 None → 호출부 기본값. + _, supplier_type = await self.crud.get_quotation_supplier_type(s, quotation_id) + + # 기존 공급가(품목 기준가) — 없으면 0(인하율 멘트 미표시). + _, item_price = await self.crud.get_item_price(s, item_id) + + # 파트너사 유형: 상품에 연결된 협력사 수(협상 세션 이력 기준 distinct supplier). 실패 시 SINGLE. + err, supplier_count = await self.crud.count_item_suppliers(s, item_id) + if err != ErrorType.SUCCESS: + supplier_count = 1 + + return NegotiationDbContext( + rq_type="재협상" if int(qt_type) in _ONE_TO_ONE_QT_TYPES else "재견적", + target_price=target, + anchor_price=anchor, + item_price=item_price, + partner_type=PartnerType.from_count(supplier_count), + revenue_amount=revenue_amount, + distribution_code=_SUPPLIER_TYPE_TO_CODE.get(supplier_type) if supplier_type else None, + ) + + try: + return await DB_SESSION_MNG.execute_lambda(DBType.MAIN.value, DBWRType.DB_READ.value, _load) + except Exception as ex: # DB 불가 등 — 컨텍스트 없이 기본값으로 진행(협상 자체는 가능해야 함) + LOG.e_no_callstack(f"[NegotiationContextLoader] 컨텍스트 조회 실패 session_id={session_id}: {ex}") + return None diff --git a/agent/negotiation/qtable/domain/model/snapshot.py b/agent/negotiation/qtable/domain/model/snapshot.py index cda5ea0..6fa5efa 100644 --- a/agent/negotiation/qtable/domain/model/snapshot.py +++ b/agent/negotiation/qtable/domain/model/snapshot.py @@ -7,7 +7,7 @@ """ from dataclasses import dataclass, asdict -from enum import Enum +from enum import Enum, IntEnum from typing import Any, Dict, Optional @@ -19,6 +19,29 @@ class NegotiationOutcome(str, Enum): FAILURE = "failure" +class PartnerType(IntEnum): + """파트너사 유형 — 상품 하나를 취급하는 협력사의 경쟁 구조. + + 상품별 협력사 수 DB 조회(NegotiationContextLoader)로 세션 시작 시 확정한다: + 없음=NONE(0), 하나=SINGLE(1), 여러 곳=MULTIPLE(2). + 값이 협력사 수와 호환되도록 설계됨(0/1/≥2) — snapshot.partner_count 로 그대로 흘러 + state 버킷(_partner_bucket)과 W 가중치 계산에 쓰인다. + """ + + NONE = 0 + SINGLE = 1 + MULTIPLE = 2 + + @classmethod + def from_count(cls, count: int) -> "PartnerType": + """협력사 수 → 유형. 0=NONE, 1=SINGLE, 2 이상=MULTIPLE.""" + if count <= 0: + return cls.NONE + if count == 1: + return cls.SINGLE + return cls.MULTIPLE + + @dataclass class NegotiationSnapshot: # --- 이산 상태 산출 입력 --- diff --git a/agent/router/router.py b/agent/router/router.py index a26c354..ff25ae5 100644 --- a/agent/router/router.py +++ b/agent/router/router.py @@ -1,11 +1,9 @@ -import os import time from contextlib import asynccontextmanager from fastapi import FastAPI, Request from fastapi.middleware.gzip import GZipMiddleware from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse from common.database.db_session_manager import DB_SESSION_MNG from common.logger import LOG @@ -22,7 +20,7 @@ API_SERVER_START_TIME = GTime.UTCStr() @asynccontextmanager async def lifespan(app: FastAPI): - # startup: 공유 베이스(_base) 없으면 자동 시드 (운영 자동화, P5) + # startup: 공유 베이스(_base) 없으면 자동 시드 (운영 자동화) from bootstrap.lifespan import ensure_base_seeded await ensure_base_seeded() yield @@ -58,16 +56,6 @@ async def log_time(request: Request, call_next): async def healthz(): return API_SERVER_START_TIME - -# 간단 테스트 프론트 (tests/negotiation_demo.html). 같은 출처로 서빙 → CORS 불필요. -_DEMO_HTML = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tests", "negotiation_demo.html") - - -@app.get(path="/demo", include_in_schema=False) -async def demo(): - return FileResponse(_DEMO_HTML) - - # 각 도메인 라우터 등록. 새 기능 추가 시 router.v1.. import 후 include. # (chat / card / learning / qtable 라우터는 P7 에서 Chat_server 14개 API 이식하며 추가) app.include_router(router.v1.health.health.router) diff --git a/agent/router/v1/chat/protocol.py b/agent/router/v1/chat/protocol.py index 380b865..e8bfaa3 100644 --- a/agent/router/v1/chat/protocol.py +++ b/agent/router/v1/chat/protocol.py @@ -8,25 +8,22 @@ from common.models.gmodel import Req_WebPacketProtocol, Res_WebPacketProtocol class Req_Chat(Req_WebPacketProtocol): - """대화 한 턴. session_id 없으면 새 협상 시작(아래 컨텍스트로). tenant 는 헤더로만.""" + """대화 한 턴. session_id 없으면 새 협상 시작. tenant 는 헤더로만. - session_id: Optional[str] = Field(None, description="없으면 새 세션 생성") - rq_type: str = Field("재협상", description="재협상 | 재견적") + 협상 컨텍스트(rq_type/목표가/앵커링가/품목가/매출액/유통코드/파트너 유형)는 요청에 싣지 + 않는다 — 세션 시작 시 agent 가 DB 에서 1회 조회해 확정한다(NegotiationContextLoader): + negotiation.sessions(qt_type·target_price·anchoring_price), partner.items(price), + partner.suppliers(total_revenue), quotation.quotations(supplier_type), 상품별 협력사 수. + 행이 없으면(데모/테스트 직접 호출) 기본값 폴백. + 가격 수용률은 세션 내 라운드별 제시가로 매 턴 동적 계산: max(0, (첫 제시가−현재가)/첫 제시가). + """ + + session_id: Optional[str] = Field(None, description="없으면 새 세션 생성. 운영 경로는 negotiation.sessions.session_id 를 그대로 사용") user_input: Optional[str] = Field(None, description="버튼 선택 텍스트 또는 가격(price 모드)") # ① desync 감지: backend 가 보는 직전 봇 step(내부 step 또는 표시 step). 없으면 검사 생략. # agent 는 자기 세션 step 을 정답으로 보고 진행하되, 불일치 시 경고 로깅하고 응답에 desynced 를 실어 # backend/front 가 agent 응답의 step/client_step 으로 리싱크하게 한다. client_step: Optional[str] = Field(None, description="backend 가 본 직전 봇 step (desync 감지용)") - # 새 세션 시작 시 협상 컨텍스트 (옵션, 기본값 제공) - revenue_amount: float = 20_000_000 - distribution_code: str = "A" - partner_count: int = 1 - acceptance_ratio: float = 0.05 - # 갑(KT/iMK)이 직접 입력. anchor < target. anchor 기본 제안값 = target*(1-0.01). - target_price: int = 10000 # KT 목표 매입가 - anchor_price: int = 9900 # KT 앵커링가(목표가보다 낮음). 제시가 ≤ anchor → 우선협상 - # 기존 공급가(품목 기준가). 가격협상_확인 멘트의 인하율(discount_rate) 산출용. 0 이면 인하율 미표시. - item_price: int = 0 class Res_Chat(Res_WebPacketProtocol): @@ -52,6 +49,9 @@ class Res_Chat(Res_WebPacketProtocol): updated_q: Optional[float] = None visit_count: Optional[int] = None reward_total: Optional[float] = None + # 성공 확정 이후 턴(협상완료 요약·협상종료)에 내려주는 합의가. 와일드카드 1% 인하 수락 등 + # 유저가 직접 입력하지 않은 가격으로 타결될 수 있어, backend 요약/입찰가는 이 값을 최우선 사용한다. + settled_price: Optional[int] = None class Res_ChatSession(Res_WebPacketProtocol): diff --git a/agent/services/chat_service.py b/agent/services/chat_service.py index ef24364..935edad 100644 --- a/agent/services/chat_service.py +++ b/agent/services/chat_service.py @@ -14,16 +14,25 @@ from config.server_configs import agent_config from negotiation.chat.service.chat_engine import ChatEngine, ChatSession, StepView from negotiation.chat.service.indicator import compute_indicator from negotiation.chat.service.chat_session_repository import ChatSessionRepository +from negotiation.chat.service.negotiation_context_loader import NegotiationContextLoader from negotiation.chat.service.script_repository import ScriptRepository from negotiation.policies.base import EpisodeState, PolicyContext, Transition from negotiation.policy.model_store import QTablePolicyStore -from negotiation.qtable.domain.model.snapshot import NegotiationOutcome, NegotiationSnapshot +from negotiation.qtable.domain.model.snapshot import NegotiationOutcome, NegotiationSnapshot, PartnerType from negotiation.qtable.domain.service.reward_calculator import RewardCalculator from negotiation.qtable.domain.service.state_calculator import state_index from negotiation.qtable.infra.repository.learning_repository import LearningRepository from router.v1.chat.protocol import Req_Chat, Res_Chat, Res_ChatSession from tenancy.registry import TenantEngine +# 직접 호출(데모/테스트) 폴백 기본 컨텍스트 — 운영 경로는 NegotiationContextLoader 가 +# DB(negotiation.sessions·partner.items·partner.suppliers·quotation.quotations)에서 조회한다. +_DEFAULT_RQ_TYPE = "재협상" +_DEFAULT_TARGET_PRICE = 10000 # KT 목표 매입가 +_DEFAULT_ANCHOR_PRICE = 9900 # 앵커링가(목표가보다 낮음). 제시가 ≤ anchor → 우선협상 +_DEFAULT_REVENUE_AMOUNT = 20_000_000 # 매출액(원) — suppliers.total_revenue 미기재 시 폴백 +_DEFAULT_DISTRIBUTION_CODE = "A" # 유통 코드 — quotations.supplier_type 미지정 시 폴백 + class ChatService: async def chat(self, engine: TenantEngine, req: Req_Chat) -> Res_Chat: @@ -33,7 +42,11 @@ class ChatService: # 1) 세션 확보 / 시작 (DB 영속 — 재시작/멀티워커 안전, P8-A) session = await sess_repo.get(req.session_id) if req.session_id else None - chat_engine = ChatEngine(repo, rq_type=(session.rq_type if session else req.rq_type)) + # 새 세션 컨텍스트: 요청 페이로드 대신 DB(negotiation.sessions 등)에서 1회 조회. + # 행이 없으면(데모/테스트 직접 호출) 기본값 폴백. + db_ctx = None if session else await NegotiationContextLoader().load(req.session_id) + rq_type = session.rq_type if session else (db_ctx.rq_type if db_ctx else _DEFAULT_RQ_TYPE) + chat_engine = ChatEngine(repo, rq_type=rq_type) # ① step desync 감지: backend 가 본 직전 봇 step(client_step)이 agent 세션 step 과 다르면 경고. # agent 가 자기 step 을 정답으로 보고 진행하고(응답의 step/client_step 으로 backend 가 따라옴), @@ -54,14 +67,22 @@ class ChatService: # 새 uuid 발급 없이 그대로 세션 키로 쓴다. 없으면(직접 호출/데모) 생성. session = ChatSession( session_id=req.session_id or str(uuid.uuid4()), tenant_id=engine.tenant_id, company_id=engine.company_id, - rq_type=req.rq_type, action_space_size=engine.action_space_size, + rq_type=rq_type, action_space_size=engine.action_space_size, context={ - "revenue_amount": req.revenue_amount, "distribution_code": req.distribution_code, - "partner_count": req.partner_count, "acceptance_ratio": req.acceptance_ratio, - # 앵커링값은 갑(KT/iMK)이 직접 입력한 값을 사용 (UI 기본값 = target*(1-rate)). - "anchor_price": req.anchor_price, "target_price": req.target_price, "round": 0, + # 매출액 = suppliers.total_revenue, 유통코드 = quotations.supplier_type 매핑 (loader). + # 미기재/미지정이면 기본값 폴백. + "revenue_amount": db_ctx.revenue_amount if db_ctx and db_ctx.revenue_amount > 0 else _DEFAULT_REVENUE_AMOUNT, + "distribution_code": db_ctx.distribution_code if db_ctx and db_ctx.distribution_code else _DEFAULT_DISTRIBUTION_CODE, + # 파트너 유형(PartnerType 값 0/1/2) — 상품별 협력사 수 DB 조회로 세션 시작 시 1회 확정. + # snapshot.partner_count 로 그대로 사용(값 호환). + # 가격 수용률은 컨텍스트에 두지 않는다 — _snapshot 이 라운드별 제시가로 동적 계산. + "partner_count": int(db_ctx.partner_type) if db_ctx else int(PartnerType.SINGLE), + # 목표가/앵커링가: sessions 행(생성 시 박제된 anchoring_price) → 박제 ‰ → 1% 폴백 (loader). + "anchor_price": db_ctx.anchor_price if db_ctx else _DEFAULT_ANCHOR_PRICE, + "target_price": db_ctx.target_price if db_ctx else _DEFAULT_TARGET_PRICE, + "round": 0, # 기존 공급가(품목 기준가) — 가격협상_확인 인하율 산출용. - "item_price": req.item_price, + "item_price": db_ctx.item_price if db_ctx else 0, }, ) view = chat_engine.start(session) @@ -78,6 +99,10 @@ class ChatService: res.chat_end = view.chat_end res.outcome = view.outcome res.desynced = desynced + # 합의가: 성공 확정 이후 턴(협상완료 요약 → 협상종료)에 내려준다. 와일드카드 수락처럼 + # 유저가 직접 입력하지 않은 가격으로 타결될 수 있어 backend 요약/입찰가는 이 값을 최우선으로 쓴다. + if session.context.get("final_outcome") == "success" and session.context.get("input_price"): + res.settled_price = int(session.context["input_price"]) # 3) 학습 결합 (가격협상 카드선택 → 카드 스크립트·협상지표 / 종료 보상) if view.error is None and engine.action_space_size > 0: @@ -112,11 +137,24 @@ class ChatService: return res # ---- 학습 ---------------------------------------------------------- + @staticmethod + def _acceptance_ratio(context: dict) -> float: + """가격 수용률 동적 계산 — 협력사 첫 제시가 대비 현재 제시가의 양보율 (설계서 공식). + + acceptance = max(0, (첫 제시가 − 현재 제시가) / 첫 제시가). + 첫 제시 라운드(양보 없음)·첫 제시가 미기록(과거 세션 호환)이면 0(low 버킷). + """ + first = context.get("first_offer_price") or 0 + current = context.get("input_price") or 0 + if first <= 0 or current <= 0: + return 0.0 + return max(0.0, (first - current) / first) + def _snapshot(self, session: ChatSession, outcome: NegotiationOutcome) -> NegotiationSnapshot: c = session.context return NegotiationSnapshot( revenue_amount=c["revenue_amount"], distribution_code=c["distribution_code"], - partner_count=c["partner_count"], acceptance_ratio=c["acceptance_ratio"], + partner_count=c["partner_count"], acceptance_ratio=self._acceptance_ratio(c), input_price=c.get("input_price", c["anchor_price"]), anchor_price=c["anchor_price"], target_price=c["target_price"], round_number=c.get("round", 0), outcome=outcome, ) diff --git a/agent/tests/negotiation_demo.html b/agent/tests/negotiation_demo.html deleted file mode 100644 index ff7d852..0000000 --- a/agent/tests/negotiation_demo.html +++ /dev/null @@ -1,142 +0,0 @@ - - - - - -Negosium Agent — 협상 채팅 (테스트) - - - -
-

Negosium Agent — 협상 채팅 /v1/chat

-

KT 구매자 관점: 협력사(판매자)가 제시가를 입력 → 앵커가 이하면 우선협상(타결), 초과면 카드로 인하 협상(카드 소진까지). 낮게 매입할수록 KT 이득.

-
- -
-
- - - - - -
- -
-
-
-
- - - - diff --git a/agent/tests/test_agent_integration.py b/agent/tests/test_agent_integration.py index 21afeb8..3539d69 100644 --- a/agent/tests/test_agent_integration.py +++ b/agent/tests/test_agent_integration.py @@ -33,8 +33,8 @@ async def test_4_1_honors_backend_session_id(db_engine): svc = ChatService() # 첫 턴: backend 의 session_id 를 그대로 키로 써야 함 (새 uuid 발급 X) - r = await svc.chat(eng, Req_Chat(session_id=BACKEND_SESSION_ID, rq_type="재협상", - target_price=10000, anchor_price=9900)) + # 컨텍스트는 DB 조회(행 없음 → 기본값 폴백: target=10000, anchor=9900) + r = await svc.chat(eng, Req_Chat(session_id=BACKEND_SESSION_ID)) assert r.session_id == BACKEND_SESSION_ID assert r.step == "서비스안내" @@ -62,8 +62,7 @@ async def test_4_4_company_id_chat_end_to_end(db_engine): reset_sessions() eng = await _reg().get_engine(COMPANY_ID) # 자동 온보딩 테넌트 svc = ChatService() - r = await svc.chat(eng, Req_Chat(session_id=BACKEND_SESSION_ID, rq_type="재협상", - target_price=10000, anchor_price=9900)) + r = await svc.chat(eng, Req_Chat(session_id=BACKEND_SESSION_ID)) assert r.session_id == BACKEND_SESSION_ID and r.step == "서비스안내" # 카드선택 턴까지 진행 → company_id 스코프로 학습 기록 for ui in ["확인", "예", "확인", "11000", "예"]: diff --git a/agent/tests/test_context_loader.py b/agent/tests/test_context_loader.py new file mode 100644 index 0000000..9d6a3a5 --- /dev/null +++ b/agent/tests/test_context_loader.py @@ -0,0 +1,202 @@ +"""NegotiationContextLoader 검증 — Req_Chat 슬림화 후 세션 시작 컨텍스트 DB 조회. + +backend 소유 스키마(negotiation.sessions / quotation.quotations / partner.items / partner.suppliers)에 +실데이터를 넣고, agent 가 session_id 만으로 rq_type·목표가·앵커링가·품목가·매출액(total_revenue)· +유통코드·파트너 유형을 확정하는지 검증한다. 삽입 행은 테스트 종료 시 삭제. +""" + +import uuid +from datetime import datetime, timedelta, timezone + +import pytest +from sqlalchemy import column, delete, insert, table + +from common.database.db_session_manager import DB_SESSION_MNG +from common.enums import DBType, ErrorType +from negotiation.chat.service.chat_session_repository import ChatSessionRepository +from router.v1.chat.protocol import Req_Chat +from services.chat_service import ChatService, reset_sessions +from tenancy.config_loader import TenantConfigLoader +from tenancy.registry import TenantEngineRegistry +import os + +_TENANTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants") + +# 삽입용 테이블 구성(backend 소유 스키마 — 테스트 데이터 셋업 전용). +_T_SESSIONS = table( + "sessions", + column("session_id"), column("quotation_id"), column("item_id"), column("supplier_id"), + column("qt_number"), column("qt_round"), column("qt_type"), column("target_price"), + column("anchoring_price"), column("status"), column("end_time"), + schema="negotiation", +) +_T_QUOTATIONS = table( + "quotations", + column("qt_id"), column("user_id"), column("qt_setting_id"), column("version_id"), + column("name"), column("number"), column("type"), column("status"), + column("start_time"), column("end_time"), column("supplier_type"), + schema="quotation", +) +_T_ITEMS = table( + "items", + column("item_id"), column("company_id"), column("user_id"), column("name"), column("price"), + schema="partner", +) +_T_SUPPLIERS = table( + "suppliers", + column("supplier_id"), column("company_id"), column("user_id"), column("name"), column("total_revenue"), + schema="partner", +) + + +@pytest.mark.asyncio +async def test_context_loaded_from_db(db_engine): + """세션 시작 시 협상 컨텍스트가 요청이 아니라 DB 에서 확정된다.""" + reset_sessions() + sid, sid2 = uuid.uuid4(), uuid.uuid4() + qid, iid = uuid.uuid4(), uuid.uuid4() + sup1, sup2 = uuid.uuid4(), uuid.uuid4() + now = datetime.now(timezone.utc) + + def _ins_item(s): + return DB_SESSION_MNG.add(s, insert(_T_ITEMS).values( + item_id=iid, company_id=uuid.uuid4(), user_id=uuid.uuid4(), + name="로더 테스트 상품", price=5000, + )) + + def _ins_supplier(s): + return DB_SESSION_MNG.add(s, insert(_T_SUPPLIERS).values( + supplier_id=sup1, company_id=uuid.uuid4(), user_id=uuid.uuid4(), + name="로더 테스트 협력사", total_revenue=55_000_000, # 매출액(KTC total_revenue 미러) + )) + + def _ins_quote(s): + return DB_SESSION_MNG.add(s, insert(_T_QUOTATIONS).values( + qt_id=qid, user_id=uuid.uuid4(), qt_setting_id=uuid.uuid4(), version_id=uuid.uuid4(), + name="로더 테스트", number="QT-LOADER-TEST", type=3, status=2, + start_time=now, end_time=now + timedelta(days=1), + supplier_type=2, # manufacture(제조) → 유통 코드 "A" + )) + + def _ins_sess(s, session_id, supplier_id): + return DB_SESSION_MNG.add(s, insert(_T_SESSIONS).values( + session_id=session_id, quotation_id=qid, item_id=iid, supplier_id=supplier_id, + qt_number="QT-LOADER-TEST", qt_round=1, + qt_type=3, # 신규협상(1:1) → 재협상 스크립트 + target_price=20000, anchoring_price=19000, # 생성 시 박제된 앵커 + status=2, end_time=now + timedelta(days=1), + )) + + err = await DB_SESSION_MNG.execute_lambda_run( + [DBType.MAIN.value], + [_ins_item, _ins_supplier, _ins_quote, + lambda s: _ins_sess(s, sid, sup1), + lambda s: _ins_sess(s, sid2, sup2)], # 같은 상품에 공급사 2곳 → MULTIPLE + ) + assert err == ErrorType.SUCCESS + + try: + reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0)) + eng = await reg.get_engine("ktcommerce") + r = await ChatService().chat(eng, Req_Chat(session_id=str(sid))) + assert r.session_id == str(sid) and r.step == "서비스안내" + + saved = await ChatSessionRepository(eng.company_id).get(str(sid)) + assert saved is not None + assert saved.rq_type == "재협상" # qt_type=3(신규협상 1:1) + c = saved.context + assert c["target_price"] == 20000 + assert c["anchor_price"] == 19000 # sessions.anchoring_price(박제) + assert c["item_price"] == 5000 + assert c["revenue_amount"] == 55_000_000.0 # 매출액 = suppliers.total_revenue + assert c["distribution_code"] == "A" # supplier_type=2(제조) → A + assert c["partner_count"] == 2 # 공급사 2곳 → MULTIPLE + finally: + await DB_SESSION_MNG.execute_lambda_run( + [DBType.MAIN.value], + [lambda s: DB_SESSION_MNG.add(s, delete(_T_SESSIONS).where(_T_SESSIONS.c.quotation_id == qid)), + lambda s: DB_SESSION_MNG.add(s, delete(_T_QUOTATIONS).where(_T_QUOTATIONS.c.qt_id == qid)), + lambda s: DB_SESSION_MNG.add(s, delete(_T_ITEMS).where(_T_ITEMS.c.item_id == iid)), + lambda s: DB_SESSION_MNG.add(s, delete(_T_SUPPLIERS).where(_T_SUPPLIERS.c.supplier_id == sup1))], + ) + + +@pytest.mark.asyncio +async def test_null_anchoring_falls_back_to_target(db_engine): + """박제 앵커(anchoring_price)가 NULL 이면 무할인 폴백 anchor=target (앵커링 v1.2 정책 승계).""" + reset_sessions() + sid, qid, iid = uuid.uuid4(), uuid.uuid4(), uuid.uuid4() + now = datetime.now(timezone.utc) + + def _ins_sess(s): + return DB_SESSION_MNG.add(s, insert(_T_SESSIONS).values( + session_id=sid, quotation_id=qid, item_id=iid, supplier_id=uuid.uuid4(), + qt_number="QT-LOADER-NULL", qt_round=1, qt_type=1, + target_price=30000, anchoring_price=None, # 박제 없음(데이터 이상 경로) + status=2, end_time=now + timedelta(days=1), + )) + + err = await DB_SESSION_MNG.execute_lambda_run([DBType.MAIN.value], [_ins_sess]) + assert err == ErrorType.SUCCESS + try: + reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0)) + eng = await reg.get_engine("ktcommerce") + await ChatService().chat(eng, Req_Chat(session_id=str(sid))) + saved = await ChatSessionRepository(eng.company_id).get(str(sid)) + assert saved is not None + assert saved.context["target_price"] == 30000 + assert saved.context["anchor_price"] == 30000 # 무할인 폴백: anchor = target + finally: + await DB_SESSION_MNG.execute_lambda_run( + [DBType.MAIN.value], + [lambda s: DB_SESSION_MNG.add(s, delete(_T_SESSIONS).where(_T_SESSIONS.c.session_id == sid))], + ) + + +@pytest.mark.asyncio +async def test_loader_with_crud_double(db_engine): + """INegoContextCRUD 인터페이스에 더블을 주입해 쿼리 없이 판정 로직만 검증 (backend crud 패턴).""" + from negotiation.chat.infra.repository.nego_context_crud import INegoContextCRUD + from negotiation.chat.service.negotiation_context_loader import NegotiationContextLoader + from negotiation.qtable.domain.model.snapshot import PartnerType + + class _FakeCRUD(INegoContextCRUD): + async def get_session_row(self, cdb, session_id): + # (qt_type, target, anchoring_price, item_id, quotation_id, supplier_id) — 재견적(2)·앵커 미박제 + return ErrorType.SUCCESS, (2, 50000, None, uuid.uuid4(), uuid.uuid4(), uuid.uuid4()) + + async def get_item_price(self, cdb, item_id): + return ErrorType.SUCCESS, 7000 + + async def get_supplier_total_revenue(self, cdb, supplier_id): + return ErrorType.SUCCESS, 12_000_000.0 + + async def get_quotation_supplier_type(self, cdb, quotation_id): + return ErrorType.SUCCESS, 3 # sole_agency(총판) → "B" + + async def count_item_suppliers(self, cdb, item_id): + return ErrorType.SUCCESS, 0 # 연결 협력사 없음 → NONE + + ctx = await NegotiationContextLoader(crud=_FakeCRUD()).load(str(uuid.uuid4())) + assert ctx is not None + assert ctx.rq_type == "재견적" # qt_type=2(1:N) + assert ctx.target_price == 50000 + assert ctx.anchor_price == 50000 # 미박제 → 무할인 폴백(anchor=target) + assert ctx.item_price == 7000 + assert ctx.revenue_amount == 12_000_000.0 + assert ctx.distribution_code == "B" # supplier_type=3(총판) → B + assert ctx.partner_type is PartnerType.NONE + + +@pytest.mark.asyncio +async def test_context_falls_back_without_db_row(db_engine): + """DB 에 세션 행이 없으면(데모/직접 호출) 기본 컨텍스트로 폴백한다.""" + reset_sessions() + reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0)) + eng = await reg.get_engine("ktcommerce") + r = await ChatService().chat(eng, Req_Chat()) + saved = await ChatSessionRepository(eng.company_id).get(r.session_id) + assert saved is not None + c = saved.context + assert c["target_price"] == 10000 and c["anchor_price"] == 9900 + assert c["partner_count"] == 1 and c["distribution_code"] == "A" diff --git a/agent/tests/test_p7_chat.py b/agent/tests/test_p7_chat.py index 7b9c70d..6d991b6 100644 --- a/agent/tests/test_p7_chat.py +++ b/agent/tests/test_p7_chat.py @@ -31,7 +31,7 @@ async def _run(svc, eng, turns): """turns: user_input 리스트(첫 None=시작). 반환: 응답 리스트.""" sid, out = None, [] for ui in turns: - r = await svc.chat(eng, Req_Chat(session_id=sid, user_input=ui, rq_type="재협상")) + r = await svc.chat(eng, Req_Chat(session_id=sid, user_input=ui)) sid = r.session_id out.append(r) if r.chat_end: @@ -76,6 +76,48 @@ async def test_full_conversation_reaches_completion(db_engine): assert cnt >= 1 +@pytest.mark.asyncio +async def test_wildcard_1pct_accept_settles_at_offer_price(db_engine): + """와일드카드 1% 인하 제안을 '예'로 수락하면 합의가는 인하 제안가(offer_1pct)여야 한다. + + 회귀: 수락해도 input_price 가 직전 제시가로 남아 요약/입찰가가 원래 가격(예: 20000)으로 + 잡히던 버그 — settled_price 가 인하가(예: 19800)로 내려와야 한다. + """ + reset_sessions() + eng = await _reg().get_engine("ktcommerce") + svc = ChatService() + # anchor=9900(기본). 10000 은 anchor*1.02(10098) 이내 → wild_card_1pct 발동, offer_1pct=9900. + out = await _run(svc, eng, [None, "확인", "예", "확인", "10000", "예", "예", + "협상 내용을 확인했으며, 이의가 없음에 동의합니다."]) + steps = [r.step for r in out] + assert "wild_card_1pct" in steps + + # 수락 후 협상완료(요약 턴)·협상종료(종료 턴) 모두 합의가 = 인하 제안가 9900 + done = [r for r in out if r.step in ("협상완료", "협상종료")] + assert done and all(r.settled_price == 9900 for r in done) + + +def test_partner_type_enum_mapping(): + """PartnerType 계약값(0=NONE/1=SINGLE/2=MULTIPLE)과 수→유형 매핑.""" + from negotiation.qtable.domain.model.snapshot import PartnerType + + assert PartnerType.NONE == 0 and PartnerType.SINGLE == 1 and PartnerType.MULTIPLE == 2 + assert PartnerType.from_count(0) is PartnerType.NONE + assert PartnerType.from_count(1) is PartnerType.SINGLE + assert PartnerType.from_count(2) is PartnerType.MULTIPLE + assert PartnerType.from_count(7) is PartnerType.MULTIPLE + + +def test_acceptance_ratio_dynamic_calc(): + """가격 수용률 동적 계산 — 첫 제시가 대비 양보율. 첫 제시/미기록=0, 인상 시 0 클립.""" + calc = ChatService._acceptance_ratio + assert calc({}) == 0.0 # 가격 입력 전 + assert calc({"first_offer_price": 11000, "input_price": 11000}) == 0.0 # 첫 제시(양보 0) + assert calc({"first_offer_price": 11000, "input_price": 10200}) == pytest.approx(800 / 11000) + assert calc({"first_offer_price": 10000, "input_price": 12000}) == 0.0 # 인상(비정상) → 0 + assert calc({"input_price": 9800}) == 0.0 # 과거 세션 호환(첫 제시가 미기록) + + @pytest.mark.asyncio async def test_priority_completes_without_wildcard(db_engine): reset_sessions() @@ -95,7 +137,7 @@ async def test_tenant_brand_isolation(db_engine): reset_sessions() svc = ChatService() reg = _reg() - ik = await svc.chat(await reg.get_engine("imarketkorea"), Req_Chat(session_id=None, rq_type="재협상")) + ik = await svc.chat(await reg.get_engine("imarketkorea"), Req_Chat(session_id=None)) assert "데모상사 B" in ik.script diff --git a/agent/tests/test_p8_session_db.py b/agent/tests/test_p8_session_db.py index 68f7c9b..f7b0f8a 100644 --- a/agent/tests/test_p8_session_db.py +++ b/agent/tests/test_p8_session_db.py @@ -27,19 +27,19 @@ async def test_session_persists_and_resumes_across_instances(db_engine): reg = _eng() eng = await reg.get_engine("ktcommerce") - # 인스턴스 1: 협상 시작 + 몇 턴 진행 + # 인스턴스 1: 협상 시작 + 몇 턴 진행 (컨텍스트는 DB 조회 — 행이 없으므로 기본값 폴백) svc1 = ChatService() - r = await svc1.chat(eng, Req_Chat(rq_type="재협상", target_price=10000, anchor_price=8000)) + r = await svc1.chat(eng, Req_Chat()) sid = r.session_id for ui in ["확인", "예", "확인", "11000"]: r = await svc1.chat(eng, Req_Chat(session_id=sid, user_input=ui)) step_before = r.step assert step_before == "가격협상_확인" # 11000 제시 후 확인 단계 - # DB 에 저장됐는지 확인 + # DB 에 저장됐는지 확인 (기본 컨텍스트: anchor=9900) saved = await ChatSessionRepository(eng.company_id).get(sid) assert saved is not None and saved.step == step_before - assert saved.context["anchor_price"] == 8000 + assert saved.context["anchor_price"] == 9900 # 인스턴스 2 (재시작/다른 워커 모사): 같은 session_id 로 이어가기 svc2 = ChatService() @@ -53,7 +53,7 @@ async def test_session_company_scoped(db_engine): reg = _eng() eng = await reg.get_engine("ktcommerce") svc = ChatService() - r = await svc.chat(eng, Req_Chat(rq_type="재협상")) + r = await svc.chat(eng, Req_Chat()) sid = r.session_id # 자사(ktcommerce)로는 조회됨 diff --git a/backend/services/agent_client.py b/backend/services/agent_client.py index 92d173c..512c1cf 100644 --- a/backend/services/agent_client.py +++ b/backend/services/agent_client.py @@ -32,26 +32,24 @@ class AgentTurn: indicator_value: Optional[float] = None # agent 가 직접 내려주는 표현 폼(summaryRSP/CM·rejectRSP/CM·indicator). 없으면 backend 가 step+qt_type 으로 폴백. bot_chat_type: Optional[str] = None + # 성공 확정 이후 턴에 agent 가 내려주는 합의가. 와일드카드 1% 인하 수락처럼 유저가 직접 + # 입력하지 않은 가격으로 타결될 수 있어, 요약 표시가·입찰가 확정 시 이 값을 최우선 사용한다. + settled_price: Optional[int] = None ok: bool = True # agent 호출 성공 여부 (False 면 CHAT_AGENT_UNAVAILABLE) timed_out: bool = False # 타임아웃 여부. True 면 agent 가 이미 진행했을 수 있어 desync 위험 → 별도 처리. @dataclass class AgentChatContext: - """새 세션 시작 시 agent 에 주입하는 협상 컨텍스트. 기존 세션이면 user_input 만 의미 있다.""" + """agent 호출 컨텍스트. 협상 컨텍스트(목표가/앵커/품목가/매출액/유통코드/파트너 유형/수용률)는 + 더 이상 전송하지 않는다 — agent 가 session_id 로 DB(negotiation.sessions 등)에서 직접 + 조회·계산한다(Req_Chat 슬림화). rq_type/target_price 는 backend 자체 로직(표현 폴백·테스트 + 더블)용으로만 유지하며 전송되지 않는다. + """ tenant_id: str # X-Tenant-ID = 견적(갑) 회사 company_id - rq_type: str = "재협상" # 재협상 | 재견적 - target_price: int = 0 # 갑 목표 매입가(원) - anchor_price: int = 0 # 앵커링가(목표가보다 낮음). 세션 생성 시 박제된 sessions.anchoring_price. - item_price: int = 0 # 기존 공급가(품목 기준가). agent 가격협상_확인 인하율 산출용. - # 핸드오프 #4: agent 의 RL 상태(state) 계산 입력. - # partner_count 는 견적당 세션 수로 산출(실데이터). 나머지 3개는 우리 스키마에 데이터 소스가 없어 - # 기본값으로 보낸다 → agent 가 실제값을 받으려면 backend 스키마에 컬럼 추가 필요(HANDOFF.md ④). - revenue_amount: float = 20_000_000 # 매출액(원) — DB 소스 없음(기본값) - distribution_code: str = "A" # 유통 코드(agent config code_map 키) — DB 소스 없음(기본값) - partner_count: int = 1 # 공급사 수 — 견적당 세션 수로 산출 - acceptance_ratio: float = 0.05 # 가격 수용률 0~1 — DB 소스 없음(기본값) + rq_type: str = "재협상" # 재협상(1:1) | 재견적(1:N) — backend 로컬 용도 + target_price: int = 0 # 갑 목표 매입가(원) — backend 로컬 용도 # 핸드오프 #1: backend 가 보는 현재 step(직전 봇 step). agent 가 자기 세션 step 과 대조해 desync 감지에 쓸 수 있다. client_step: Optional[str] = None extra: dict = field(default_factory=dict) @@ -72,16 +70,9 @@ class HttpAgentClient(IAgentClient): body = { "session_id": session_id, # 핸드오프 #1: agent 가 이 값을 세션 키로 그대로 사용해야 함 - "rq_type": ctx.rq_type, + # 협상 컨텍스트(rq_type/목표가/앵커/품목가/매출액/유통코드/파트너 유형/수용률)는 보내지 + # 않는다 — agent 가 session_id 로 DB 에서 직접 조회·계산한다(NegotiationContextLoader). "user_input": user_input, - "target_price": ctx.target_price, - "anchor_price": ctx.anchor_price, - "item_price": ctx.item_price, - # 핸드오프 #4: RL state 입력 (agent Req_Chat 이 받는 필드). 현재 기본값. - "revenue_amount": ctx.revenue_amount, - "distribution_code": ctx.distribution_code, - "partner_count": ctx.partner_count, - "acceptance_ratio": ctx.acceptance_ratio, # 핸드오프 #1: backend 가 보는 직전 step. agent 가 desync 감지에 사용(미구현 시 무시됨). "client_step": ctx.client_step, } @@ -111,6 +102,7 @@ class HttpAgentClient(IAgentClient): card_id=data.get("card_id"), indicator_value=data.get("indicator_value"), bot_chat_type=data.get("bot_chat_type"), + settled_price=data.get("settled_price"), ok=True, ) diff --git a/backend/services/chat_service.py b/backend/services/chat_service.py index 9ebedc3..ddcf4f3 100644 --- a/backend/services/chat_service.py +++ b/backend/services/chat_service.py @@ -361,7 +361,9 @@ class ChatService: summary = None if bot_chat_type in ("summaryRSP", "summaryCM"): final_price = price if price is not None else (sess.bid_price or sess.target_price) - summary = await self._build_summary(sess, quote, item, final_price, last_price) + # 합의가는 agent 가 내려준 settled_price 최우선 (와일드카드 1% 인하 수락 등 + # 유저 미입력 가격 타결 케이스 — 마지막 유저 제시가와 다를 수 있다). + summary = await self._build_summary(sess, quote, item, final_price, turn.settled_price or last_price) # 봇 메시지 + 종료 시 확정(성공=DONE+입찰가 / 실패=REJECTED+거부사유·제시가). 한 트랜잭션. bot_msg = self._build_bot_chat(sess, seq=max_seq + 2, turn=turn, bot_chat_type=bot_chat_type, summary=summary) @@ -374,10 +376,11 @@ class ChatService: if turn.chat_end: if turn.outcome == "success": new_status = SessionStatus.DONE.value - # 입찰가 = 이번 턴 가격(보통 None) → 마지막 제시가 → 목표가 순으로 확정. - bid = price if price is not None else (last_price if last_price else sess.target_price) + # 입찰가 = agent 합의가(settled_price, 와일드카드 수락 등) → 이번 턴 가격(보통 None) + # → 마지막 제시가 → 목표가 순으로 확정. + bid = turn.settled_price or (price if price is not None else (last_price if last_price else sess.target_price)) # ⑤ 협상된 제시가가 하나도 없어 목표가로 폴백하면, 합의가가 실제 협상과 다를 수 있어 경고. - if price is None and not last_price: + if not turn.settled_price and price is None and not last_price: LOG.w(f"[chat] 합의가 폴백→목표가 session_id={sess.session_id} bid={bid} " f"— 협상 중 가격 제시가 기록되지 않음(프론트 user_input_type='price' 누락 의심)") funcs.append(lambda s: self.chat_crud.finalize_session(s, sess.session_id, new_status, bid_price=bid)) @@ -417,52 +420,18 @@ class ChatService: tenant_id = str(item.company_id) else: LOG.w(f"[chat] tenant_id 해석 실패(item.company_id 없음) session_id={sess.session_id} — agent 400 위험") - rq_type = "재협상" if sess.qt_type == 1 else "재견적" + # 1:1 견적유형(1=renego, 3=new_nego) → 재협상, 1:N(2=requote, 4=new_quote) → 재견적. + # agent 도 sessions.qt_type 으로 동일 판별(NegotiationContextLoader) — backend 로컬 용도. + rq_type = "재협상" if sess.qt_type in (1, 3) else "재견적" target_price = int(sess.target_price or 0) - # 앵커가: 세션 생성 시 박제된 값(anchoring_price)을 그대로 사용 — 협상 중 불변. - anchor = await self._resolve_anchor_price(sess, target_price) - # 공급사 수: 같은 견적에 속한 세션 수(재협상=1, 재견적=N). agent partner 차원(single/multiple/none) 입력. - partner_count = await self._count_partners(sess) - # revenue_amount / distribution_code / acceptance_ratio 는 현재 스키마에 데이터 소스가 없어 - # AgentChatContext 기본값으로 보낸다(HANDOFF #4 — 컬럼 추가/소스 합의 필요). - # 기존 공급가(품목 기준가) — agent 가격협상_확인 인하율(discount_rate) 산출 입력. - item_price = int(item.price) if item is not None and item.price else 0 + # 협상 컨텍스트(앵커가/품목가/매출액/유통코드/파트너 유형/수용률)는 더 이상 계산·전송하지 않는다 — + # agent 가 session_id 로 DB(negotiation.sessions·partner.items/suppliers·quotations)에서 직접 조회한다. + # (앵커가 박제·무할인 폴백 정책 — schedules/anchoring/docs/개발용.md §9.2 — 은 agent loader 가 승계.) return AgentChatContext( tenant_id=tenant_id, rq_type=rq_type, - target_price=target_price, anchor_price=anchor, partner_count=partner_count, - item_price=item_price, client_step=client_step, + target_price=target_price, client_step=client_step, ) - async def _resolve_anchor_price(self, sess, target_price: int) -> int: - """세션에 박제된 앵커가(anchoring_price — negodata 가 생성 시 기록)를 그대로 사용. - - 박제값 사용이 정상 경로다: 협상 진행 중 앵커링 배치 조정·재기동이 껴도 앵커가 흔들리지 않는다 - ("제안 당시 값" 판정의 전제 — schedules/anchoring/docs/개발용.md §9.2). backend 는 앵커를 계산하지 않는다. - 박제가 없으면(데이터 이상 — 사실상 발생하지 않음) 무할인 폴백 anchor=target + WARN. - 이때 박제하지 않으므로 해당 세션은 앵커링 집계에서 자동 제외(EXCLUDED)된다 — 학습 무오염. - """ - if not target_price: - return 0 - if sess.anchoring_price is not None: - return int(sess.anchoring_price) - LOG.w(f"[chat] 앵커가 박제 없음 session_id={sess.session_id} — 무할인 폴백(anchor=target), 집계 제외") - return target_price - - async def _count_partners(self, sess) -> int: - """같은 견적(quotation_id)에 속한 협상 세션 수 = 참여 공급사 수. 실패 시 1 폴백.""" - def _q(s): - stmt = ( - select(func.count()) - .select_from(sessions) - .where(sessions.quotation_id == sess.quotation_id, sessions.deleted == False) # noqa: E712 - ) - return DB_SESSION_MNG.execute(s, stmt) - - err_type, rows = await DB_SESSION_MNG.execute_lambda(sessions.DBType(), DBWRType.DB_READ.value, _q) - if err_type != ErrorType.SUCCESS or not rows or not rows[0]: - return 1 - return int(rows[0]) - async def _last_user_price(self, sess) -> Optional[int]: """세션에서 가장 최근 유저 제시가(negotiation.chats.target_price>0). 없으면 None.""" def _q(s): diff --git a/backend/tests/test_anchoring_chat.py b/backend/tests/test_anchoring_chat.py index 7470b58..3aaede0 100644 --- a/backend/tests/test_anchoring_chat.py +++ b/backend/tests/test_anchoring_chat.py @@ -32,15 +32,17 @@ class _AnchorAgent(IAgentClient): """결정론적 더블: 서비스안내(오프닝) → 가격 입력 요청 → 합의 종료. 앵커보다 높은 가격이면 같은 step 을 반복(마지막 제시가 덮어쓰기 검증용). - 매 턴 수신한 ctx.anchor_price 를 기록해 backend 의 앵커 해석을 관찰한다. - (script 에 앵커가 보이는 건 테스트 관찰 편의일 뿐 — 실제 agent 는 비노출.) + 앵커 해석(박제값 소비/무할인 폴백)은 agent 가 DB 에서 직접 수행하도록 이관됐다 + (NegotiationContextLoader — agent tests/test_context_loader.py 가 검증). 여기 더블은 + 자체 앵커 상수로 수락 여부만 판정하고, backend 가 보내는 ctx.target_price 를 관찰한다. """ - def __init__(self): - self.seen_anchors: list[int] = [] + def __init__(self, anchor: int = ANCHOR): + self.anchor = anchor + self.seen_targets: list[int] = [] async def chat(self, session_id, user_input, ctx) -> AgentTurn: - self.seen_anchors.append(ctx.anchor_price) + self.seen_targets.append(ctx.target_price) sid = session_id or "fake-session" if user_input is None: # 오프닝(턴0) return AgentTurn(session_id=sid, step="서비스안내", client_step="서비스안내", @@ -48,10 +50,10 @@ class _AnchorAgent(IAgentClient): input_options=["네, 시작할게요"]) if ctx.client_step == "서비스안내": return AgentTurn(session_id=sid, step="기존가격제시", client_step="기존가격제시", - script=f"저희가 제안드리는 첫 목표 가격은 {ctx.anchor_price}원입니다. " + script=f"저희가 제안드리는 첫 목표 가격은 {self.anchor}원입니다. " f"제안하실 가격을 입력해 주세요.", input_mode="price") price = _parse_price(user_input) - if price is not None and price <= ctx.anchor_price: + if price is not None and price <= self.anchor: return AgentTurn(session_id=sid, step="협상종료", client_step="협상종료", script=f"{price:,}원으로 합의되었습니다.", chat_end=True, outcome="success") return AgentTurn(session_id=sid, step="기존가격제시", client_step="기존가격제시", @@ -159,7 +161,7 @@ async def test_snapshot_consumed_and_last_offer_recorded(client, db_engine, anch assert r.status_code == 200 and r.json()["message"]["step"] == "기존가격제시" row = await _anchor_columns(db_engine, sid) assert row.last_offer_price is None - assert _fake_agent.seen_anchors[-1] == ANCHOR # backend 가 박제값을 그대로 전달 + assert _fake_agent.seen_targets[-1] == TARGET # backend 로컬 컨텍스트(target) 전달 확인 r = await _send(client, token, sid, "99,500", "price") # 앵커 초과 → 같은 step 반복 assert r.json()["message"]["step"] == "기존가격제시" @@ -175,7 +177,8 @@ async def test_snapshot_consumed_and_last_offer_recorded(client, db_engine, anch assert (row.anchoring_price, row.anchoring_value) == (ANCHOR, 10) # 박제 불변 -# ── 폴백 경로: 박제 NULL → 무할인(anchor=target) + 미박제 유지 ── +# ── 폴백 경로: 박제 NULL 세션도 backend 채팅 경로가 정상 동작 + 미박제 유지 ── +# (무할인 폴백 anchor=target 자체는 agent NegotiationContextLoader 가 수행/검증 — agent 테스트 소관) async def test_null_snapshot_falls_back_to_target(client, db_engine, anchor_seed, _fake_agent): sid = str(anchor_seed["sids"]["N"]) token = await _login_token(client) @@ -183,11 +186,13 @@ async def test_null_snapshot_falls_back_to_target(client, db_engine, anchor_seed await _messages(client, token, sid) r = await _send(client, token, sid, "네, 시작할게요") assert r.json()["message"]["step"] == "기존가격제시" - assert _fake_agent.seen_anchors[-1] == TARGET # 무할인 폴백: anchor = target - r = await _send(client, token, sid, "97,000", "price") # 가격 입력(폴백 앵커 이하 → 종료) + r = await _send(client, token, sid, "97,000", "price") # 가격 입력(더블 앵커 이하 → 종료) assert r.json()["session_status"] == 3 row = await _anchor_columns(db_engine, sid) + assert (row.anchoring_price, row.anchoring_value) == (None, None) # 미박제 유지(집계 제외 조건) + assert row.bid_price == 97_000 + row = await _anchor_columns(db_engine, sid) assert row.anchoring_price is None # backend 는 박제하지 않음(앵커 없음 → 집계 제외) assert row.anchoring_value is None assert row.last_offer_price == 97_000 # 가격 흔적 기록은 정상 동작 diff --git a/negodata/backend/common/database/model/models.py b/negodata/backend/common/database/model/models.py index caffd17..48e7eaf 100644 --- a/negodata/backend/common/database/model/models.py +++ b/negodata/backend/common/database/model/models.py @@ -124,7 +124,19 @@ class suppliers(MainTableMixin, MAIN_BASE): manager_name = Column(String(50), nullable=True) manager_email = Column(String(255), nullable=True) manager_contact_number = Column(String(20), nullable=True) # ERD 오타(manger) 교정 - total_revenue = Column(BigInteger, nullable=True) # 총매출액(원) + total_revenue = Column(BigInteger, nullable=True) # 총매출액 + + +class supplier_items(MainTableMixin, MAIN_BASE): + __tablename__ = "supplier_items" + # (supplier_id, item_id) 유일성은 soft-delete 인지 부분 유니크 인덱스(uq_supplier_items, WHERE deleted=FALSE)로 DB에서 보장. + __table_args__ = {"schema": "partner"} + + supplier_item_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + supplier_id = Column(UUID(as_uuid=True), nullable=False, index=True) # suppliers.supplier_id + item_id = Column(UUID(as_uuid=True), nullable=False, index=True) # items.item_id + # SupplierType: 이 협력사가 이 상품을 공급하는 방식(0=없음/1=유통/2=제조/3=총판). quotations.supplier_type 와 값은 같으나 의미 단위가 (협력사,상품)이라 컬럼명은 supply_type. + supply_type = Column(SmallInteger, nullable=False, server_default=text("0"), default=0) class nego_cards(MainTableMixin, MAIN_BASE): diff --git a/negodata/backend/common/enums.py b/negodata/backend/common/enums.py index 82f2e2e..80410ee 100644 --- a/negodata/backend/common/enums.py +++ b/negodata/backend/common/enums.py @@ -57,6 +57,7 @@ class ErrorType(Enum): # 협력사 관련 에러 SUPPLIER_NOT_FOUND = 1400 SUPPLIER_CODE_DUPLICATE = auto() + SUPPLIER_ITEM_NOT_FOUND = auto() # 협력사-상품 매핑 미존재 # 견적 관련 에러 QUOTATION_NOT_FOUND = 1500 diff --git a/negodata/backend/crud/supplier_item_crud.py b/negodata/backend/crud/supplier_item_crud.py new file mode 100644 index 0000000..b372cd6 --- /dev/null +++ b/negodata/backend/crud/supplier_item_crud.py @@ -0,0 +1,175 @@ +from abc import ABC, abstractmethod +from typing import Tuple + +from sqlalchemy import select, func, update +from sqlalchemy.ext.asyncio import AsyncSession + +from common.database.db_session_manager import DB_SESSION_MNG +from common.database.model.models import supplier_items, items +from common.enums import ErrorType +from common.logger import LOG +from common.utils.gtime import GTime + + +# 협력사-상품 매핑 CRUD. 스코프는 상위(협력사/상품)가 company_id 로 이미 걸린다. +class ISupplierItemCRUD(ABC): + @abstractmethod + async def list_by_supplier(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, list]: + pass + + @abstractmethod + async def list_by_item(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, list]: + pass + + @abstractmethod + async def get_by_id(self, cdb: AsyncSession, supplier_item_id) -> Tuple[ErrorType, supplier_items]: + pass + + @abstractmethod + async def existing_item_ids(self, cdb: AsyncSession, supplier_id, item_ids: list) -> Tuple[ErrorType, list]: + pass + + @abstractmethod + async def find_items_by_names(self, cdb: AsyncSession, company_id, names: list) -> Tuple[ErrorType, list]: + pass + + @abstractmethod + async def add_many(self, cdb: AsyncSession, mappings: list) -> ErrorType: + pass + + @abstractmethod + async def update_type(self, cdb: AsyncSession, supplier_item_id, supply_type: int) -> ErrorType: + pass + + @abstractmethod + async def soft_delete(self, cdb: AsyncSession, supplier_item_id) -> ErrorType: + pass + + +class SupplierItemCRUD(ISupplierItemCRUD): + async def list_by_supplier(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, list]: + # 협력사 상세용: 매핑 + 상품명/코드 조인. Row(supplier_item_id, item_id, name, code, supply_type) + try: + query = ( + select( + supplier_items.supplier_item_id, + supplier_items.item_id, + items.name, + items.code, + supplier_items.supply_type, + ) + .join(items, items.item_id == supplier_items.item_id) + .where( + supplier_items.supplier_id == supplier_id, + supplier_items.deleted == False, # noqa: E712 + items.deleted == False, # noqa: E712 + ) + .order_by(supplier_items.created_at.desc()) + ) + err_type, rows = await DB_SESSION_MNG.execute(cdb, query) + if err_type != ErrorType.SUCCESS: + return err_type, [] + return ErrorType.SUCCESS, list(rows) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, [] + + async def list_by_item(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, list]: + # 견적생성 모달용: 이 상품을 취급하는 협력사별 공급유형. Row(supplier_id, supply_type) + try: + query = select(supplier_items.supplier_id, supplier_items.supply_type).where( + supplier_items.item_id == item_id, + supplier_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, list(rows) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, [] + + async def get_by_id(self, cdb: AsyncSession, supplier_item_id) -> Tuple[ErrorType, supplier_items]: + try: + query = select(supplier_items).where( + supplier_items.supplier_item_id == supplier_item_id, + supplier_items.deleted == False, # noqa: E712 + ).limit(1) + err_type, row_list = await DB_SESSION_MNG.execute(cdb, query) + if err_type != ErrorType.SUCCESS: + return err_type, None + if len(row_list) != 1: + return ErrorType.DB_INVALID_KEY, None + return ErrorType.SUCCESS, row_list[0] + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, None + + async def existing_item_ids(self, cdb: AsyncSession, supplier_id, item_ids: list) -> Tuple[ErrorType, list]: + # 이 협력사에 이미 매핑된 item_id 들(중복 등록 스킵용). + try: + if not item_ids: + return ErrorType.SUCCESS, [] + query = select(supplier_items.item_id).where( + supplier_items.supplier_id == supplier_id, + supplier_items.item_id.in_(item_ids), + supplier_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 for r in rows if r is not None] + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, [] + + async def find_items_by_names(self, cdb: AsyncSession, company_id, names: list) -> Tuple[ErrorType, list]: + # 상품명(정확 일치) → 상품. Row(item_id, name). 이름 중복 상품이 있으면 여럿 반환될 수 있다(서비스에서 첫 매칭 사용). + try: + if not names: + return ErrorType.SUCCESS, [] + query = select(items.item_id, items.name).where( + items.company_id == company_id, + items.name.in_(names), + 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, list(rows) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, [] + + async def add_many(self, cdb: AsyncSession, mappings: list) -> ErrorType: + try: + if not mappings: + return ErrorType.SUCCESS + return await DB_SESSION_MNG.insert(cdb, mappings) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED + + async def update_type(self, cdb: AsyncSession, supplier_item_id, supply_type: int) -> ErrorType: + try: + query = ( + update(supplier_items) + .where(supplier_items.supplier_item_id == supplier_item_id) + .values(supply_type=supply_type, updated_at=GTime.UTC()) + ) + return await DB_SESSION_MNG.add(cdb, query) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED + + async def soft_delete(self, cdb: AsyncSession, supplier_item_id) -> ErrorType: + try: + query = ( + update(supplier_items) + .where(supplier_items.supplier_item_id == supplier_item_id) + .values(deleted=True, updated_at=GTime.UTC()) + ) + 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 f2ceabc..3bc2dd3 100644 --- a/negodata/backend/router/router.py +++ b/negodata/backend/router/router.py @@ -14,6 +14,7 @@ import router.v1.auth.account import router.v1.company.user import router.v1.item.item import router.v1.supplier.supplier +import router.v1.supplier_item.supplier_item import router.v1.card.card import router.v1.quotation.quotation import router.v1.quotation_setting.quotation_setting @@ -68,6 +69,7 @@ app.include_router(router.v1.auth.account.router) app.include_router(router.v1.company.user.router) app.include_router(router.v1.item.item.router) app.include_router(router.v1.supplier.supplier.router) +app.include_router(router.v1.supplier_item.supplier_item.router) app.include_router(router.v1.card.card.router) app.include_router(router.v1.quotation.quotation.router) app.include_router(router.v1.quotation_setting.quotation_setting.router) diff --git a/negodata/backend/router/v1/supplier_item/protocol.py b/negodata/backend/router/v1/supplier_item/protocol.py new file mode 100644 index 0000000..5fda395 --- /dev/null +++ b/negodata/backend/router/v1/supplier_item/protocol.py @@ -0,0 +1,60 @@ +import uuid +from datetime import datetime +from typing import Optional + +from pydantic import ConfigDict + +from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol + + +class SupplierItemProtocol(WebPacketProtocol): + pass + + +class Req_CreateSupplierItem(SupplierItemProtocol): + supplier_id: uuid.UUID + item_id: uuid.UUID + supply_type: int = 0 + + +class Req_UpdateSupplyType(SupplierItemProtocol): + supply_type: int + + +class Req_BulkMapByNames(SupplierItemProtocol): + names: list[str] = [] + + +class SupplierItemData(WebPacketProtocol): + model_config = ConfigDict(from_attributes=True) + + supplier_item_id: uuid.UUID + item_id: uuid.UUID + item_name: str + item_code: Optional[str] = None + supply_type: int + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + +class ItemSupplyType(WebPacketProtocol): + supplier_id: uuid.UUID + supply_type: int + + +class Res_SupplierItem(Res_WebPacketProtocol): + supplier_item: Optional[SupplierItemData] = None + + +class Res_SupplierItemList(Res_WebPacketProtocol): + supplier_items: list[SupplierItemData] = [] + + +class Res_ItemSupplyTypeList(Res_WebPacketProtocol): + suppliers: list[ItemSupplyType] = [] + + +class Res_BulkMapByNames(Res_WebPacketProtocol): + created_count: int = 0 + skipped_count: int = 0 # 이미 매핑돼 있어 건너뛴 상품 수 + unmatched: list[str] = [] # 회사 상품 목록에 이름이 없어 매핑 못한 입력명들 diff --git a/negodata/backend/router/v1/supplier_item/supplier_item.py b/negodata/backend/router/v1/supplier_item/supplier_item.py new file mode 100644 index 0000000..b1a5247 --- /dev/null +++ b/negodata/backend/router/v1/supplier_item/supplier_item.py @@ -0,0 +1,61 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends + +from common.models.gmodel import Res_WebPacketProtocol, UserInfo +from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse +from services.supplier_item_service import SupplierItemService +from .protocol import ( + Req_BulkMapByNames, + Req_CreateSupplierItem, + Req_UpdateSupplyType, + Res_BulkMapByNames, + Res_ItemSupplyTypeList, + Res_SupplierItem, + Res_SupplierItemList, +) + +# 협력사-상품 매핑 라우터. company_id 스코프는 상위 협력사/상품 소유권으로 확인. +router = APIRouter(prefix="/v1/supplier-item", tags=["SupplierItem"], responses={404: {"description": "Not found"}}) + + +@router.get(path="/by-supplier/{supplier_id}", response_model=Res_SupplierItemList, summary="협력사 취급상품 목록") +async def list_supplier_items( + supplier_id: UUID, service: SupplierItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken) +): + return RemoveNoneResponse(await service.list_by_supplier(user_info.company_id, str(supplier_id))) + + +@router.get(path="/by-item/{item_id}", response_model=Res_ItemSupplyTypeList, summary="상품 취급 협력사 공급유형 목록") +async def list_item_supply_types( + item_id: UUID, service: SupplierItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken) +): + return RemoveNoneResponse(await service.list_by_item(user_info.company_id, str(item_id))) + + +@router.post(path="/create", response_model=Res_SupplierItem, summary="취급상품 매핑 추가") +async def create_supplier_item( + req: Req_CreateSupplierItem, service: SupplierItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken) +): + return RemoveNoneResponse(await service.create(user_info.company_id, req)) + + +@router.post(path="/by-supplier/{supplier_id}/bulk", response_model=Res_BulkMapByNames, summary="취급상품 이름 일괄 매핑(엑셀 업로드)") +async def bulk_map_supplier_items( + supplier_id: UUID, req: Req_BulkMapByNames, service: SupplierItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken) +): + return RemoveNoneResponse(await service.bulk_map_by_names(user_info.company_id, str(supplier_id), req.names)) + + +@router.patch(path="/update/{supplier_item_id}", response_model=Res_WebPacketProtocol, summary="취급상품 공급유형 수정") +async def update_supply_type( + supplier_item_id: UUID, req: Req_UpdateSupplyType, service: SupplierItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken) +): + return RemoveNoneResponse(await service.update_type(user_info.company_id, str(supplier_item_id), req)) + + +@router.delete(path="/delete/{supplier_item_id}", response_model=Res_WebPacketProtocol, summary="취급상품 매핑 삭제") +async def delete_supplier_item( + supplier_item_id: UUID, service: SupplierItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken) +): + return RemoveNoneResponse(await service.delete(user_info.company_id, str(supplier_item_id))) diff --git a/negodata/backend/services/supplier_item_service.py b/negodata/backend/services/supplier_item_service.py new file mode 100644 index 0000000..aff5f35 --- /dev/null +++ b/negodata/backend/services/supplier_item_service.py @@ -0,0 +1,264 @@ +import uuid + +from fastapi import Depends + +from common.database.db_session_manager import DB_SESSION_MNG +from common.database.model.models import supplier_items +from common.enums import DBWRType, ErrorType, SupplierType +from common.models.gmodel import Res_WebPacketProtocol +from crud.item_crud import IItemCRUD, ItemCRUD +from crud.supplier_crud import ISupplierCRUD, SupplierCRUD +from crud.supplier_item_crud import ISupplierItemCRUD, SupplierItemCRUD +from router.v1.supplier_item.protocol import ( + ItemSupplyType, + Req_CreateSupplierItem, + Req_UpdateSupplyType, + Res_BulkMapByNames, + Res_ItemSupplyTypeList, + Res_SupplierItem, + Res_SupplierItemList, + SupplierItemData, +) + +_VALID_SUPPLY_TYPES = {e.value for e in SupplierType} + + +class SupplierItemService: + """협력사-상품 매핑 로직. 소유권은 상위 협력사/상품의 company_id 로 확인한다(멀티테넌트).""" + + def __init__( + self, + supplier_item_crud: ISupplierItemCRUD = Depends(SupplierItemCRUD), + supplier_crud: ISupplierCRUD = Depends(SupplierCRUD), + item_crud: IItemCRUD = Depends(ItemCRUD), + ): + self.supplier_item_crud = supplier_item_crud + self.supplier_crud = supplier_crud + self.item_crud = item_crud + + async def _supplier_owned(self, company_uuid: uuid.UUID, supplier_id: uuid.UUID) -> ErrorType: + err_type, supplier = await DB_SESSION_MNG.execute_lambda( + supplier_items.DBType(), + DBWRType.DB_READ.value, + lambda s: self.supplier_crud.get_by_id(s, supplier_id), + ) + if err_type != ErrorType.SUCCESS or supplier is None or supplier.company_id != company_uuid: + return ErrorType.SUPPLIER_NOT_FOUND + return ErrorType.SUCCESS + + async def _item_owned(self, company_uuid: uuid.UUID, item_id: uuid.UUID): + err_type, item = await DB_SESSION_MNG.execute_lambda( + supplier_items.DBType(), + DBWRType.DB_READ.value, + lambda s: self.item_crud.get_by_id(s, item_id), + ) + if err_type != ErrorType.SUCCESS or item is None or item.company_id != company_uuid: + return ErrorType.ITEM_NOT_FOUND, None + return ErrorType.SUCCESS, item + + async def _fetch_owned_mapping(self, company_uuid: uuid.UUID, supplier_item_id: uuid.UUID): + """매핑 조회 + 소유 협력사 확인. (ErrorType, mapping|None) 반환.""" + err_type, mapping = await DB_SESSION_MNG.execute_lambda( + supplier_items.DBType(), + DBWRType.DB_READ.value, + lambda s: self.supplier_item_crud.get_by_id(s, supplier_item_id), + ) + if err_type != ErrorType.SUCCESS or mapping is None: + return ErrorType.SUPPLIER_ITEM_NOT_FOUND, None + own_err = await self._supplier_owned(company_uuid, mapping.supplier_id) + if own_err != ErrorType.SUCCESS: + return ErrorType.SUPPLIER_ITEM_NOT_FOUND, None + return ErrorType.SUCCESS, mapping + + async def list_by_supplier(self, company_id: str, supplier_id: str) -> Res_SupplierItemList: + res = Res_SupplierItemList() + own_err = await self._supplier_owned(uuid.UUID(company_id), uuid.UUID(supplier_id)) + if own_err != ErrorType.SUCCESS: + res.result.SetResult(own_err) + return res + + err_type, rows = await DB_SESSION_MNG.execute_lambda( + supplier_items.DBType(), + DBWRType.DB_READ.value, + lambda s: self.supplier_item_crud.list_by_supplier(s, uuid.UUID(supplier_id)), + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + # Row(supplier_item_id, item_id, name, code, supply_type) + res.supplier_items = [ + SupplierItemData( + supplier_item_id=r[0], item_id=r[1], item_name=r[2], item_code=r[3], supply_type=r[4] + ) + for r in rows + ] + return res + + async def list_by_item(self, company_id: str, item_id: str) -> Res_ItemSupplyTypeList: + res = Res_ItemSupplyTypeList() + own_err, _ = await self._item_owned(uuid.UUID(company_id), uuid.UUID(item_id)) + if own_err != ErrorType.SUCCESS: + res.result.SetResult(own_err) + return res + + err_type, rows = await DB_SESSION_MNG.execute_lambda( + supplier_items.DBType(), + DBWRType.DB_READ.value, + lambda s: self.supplier_item_crud.list_by_item(s, uuid.UUID(item_id)), + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + # Row(supplier_id, supply_type) + res.suppliers = [ItemSupplyType(supplier_id=r[0], supply_type=r[1]) for r in rows] + return res + + async def create(self, company_id: str, req: Req_CreateSupplierItem) -> Res_SupplierItem: + res = Res_SupplierItem() + company_uuid = uuid.UUID(company_id) + + if req.supply_type not in _VALID_SUPPLY_TYPES: + res.result.SetResult(ErrorType.INVALID_REQUEST_DATA) + return res + + own_err = await self._supplier_owned(company_uuid, req.supplier_id) + if own_err != ErrorType.SUCCESS: + res.result.SetResult(own_err) + return res + item_err, item = await self._item_owned(company_uuid, req.item_id) + if item_err != ErrorType.SUCCESS: + res.result.SetResult(item_err) + return res + + # 활성 중복 매핑 차단(부분 유니크 인덱스와 이중 방어). + dup_err, existing = await DB_SESSION_MNG.execute_lambda( + supplier_items.DBType(), + DBWRType.DB_READ.value, + lambda s: self.supplier_item_crud.existing_item_ids(s, req.supplier_id, [req.item_id]), + ) + if dup_err != ErrorType.SUCCESS: + res.result.SetResult(dup_err) + return res + if existing: + res.result.SetResult(ErrorType.DB_ALREADY_SAME_KEY) + return res + + mapping = supplier_items(supplier_id=req.supplier_id, item_id=req.item_id, supply_type=req.supply_type) + err_type = await DB_SESSION_MNG.execute_lambda_run( + [supplier_items.DBType()], + [lambda s: self.supplier_item_crud.add_many(s, [mapping])], + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + res.supplier_item = SupplierItemData( + supplier_item_id=mapping.supplier_item_id, + item_id=item.item_id, + item_name=item.name, + item_code=item.code, + supply_type=req.supply_type, + ) + return res + + async def update_type(self, company_id: str, supplier_item_id: str, req: Req_UpdateSupplyType) -> Res_WebPacketProtocol: + res = Res_WebPacketProtocol() + if req.supply_type not in _VALID_SUPPLY_TYPES: + res.result.SetResult(ErrorType.INVALID_REQUEST_DATA) + return res + + err_type, mapping = await self._fetch_owned_mapping(uuid.UUID(company_id), uuid.UUID(supplier_item_id)) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + err_type = await DB_SESSION_MNG.execute_lambda_run( + [supplier_items.DBType()], + [lambda s: self.supplier_item_crud.update_type(s, mapping.supplier_item_id, req.supply_type)], + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + async def delete(self, company_id: str, supplier_item_id: str) -> Res_WebPacketProtocol: + res = Res_WebPacketProtocol() + err_type, mapping = await self._fetch_owned_mapping(uuid.UUID(company_id), uuid.UUID(supplier_item_id)) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + err_type = await DB_SESSION_MNG.execute_lambda_run( + [supplier_items.DBType()], + [lambda s: self.supplier_item_crud.soft_delete(s, mapping.supplier_item_id)], + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + + async def bulk_map_by_names(self, company_id: str, supplier_id: str, names: list) -> Res_BulkMapByNames: + """엑셀 취급상품 업로드용: 상품명 리스트 → 매핑 생성. 미매칭명은 스킵 후 리포트, 이미 매핑된 상품도 스킵. + 업로드는 공급유형을 받지 않으므로 전부 없음(0)으로 들어간다(상세에서 편집).""" + res = Res_BulkMapByNames() + company_uuid = uuid.UUID(company_id) + supplier_uuid = uuid.UUID(supplier_id) + + own_err = await self._supplier_owned(company_uuid, supplier_uuid) + if own_err != ErrorType.SUCCESS: + res.result.SetResult(own_err) + return res + + # 입력 정규화: 공백 제거·빈값 제거·중복 제거(원문 순서 유지). + seen = set() + clean_names = [] + for raw in names: + n = (raw or "").strip() + if n and n not in seen: + seen.add(n) + clean_names.append(n) + if not clean_names: + return res + + # 이름 → 상품 해석(정확 일치). 이름 중복 상품은 첫 매칭만 사용. + find_err, rows = await DB_SESSION_MNG.execute_lambda( + supplier_items.DBType(), + DBWRType.DB_READ.value, + lambda s: self.supplier_item_crud.find_items_by_names(s, company_uuid, clean_names), + ) + if find_err != ErrorType.SUCCESS: + res.result.SetResult(find_err) + return res + name_to_item = {} + for r in rows: # Row(item_id, name) + if r[1] not in name_to_item: + name_to_item[r[1]] = r[0] + + res.unmatched = [n for n in clean_names if n not in name_to_item] + matched_item_ids = [name_to_item[n] for n in clean_names if n in name_to_item] + if not matched_item_ids: + return res + + # 이미 이 협력사에 매핑된 상품 제외. + exist_err, already = await DB_SESSION_MNG.execute_lambda( + supplier_items.DBType(), + DBWRType.DB_READ.value, + lambda s: self.supplier_item_crud.existing_item_ids(s, supplier_uuid, matched_item_ids), + ) + if exist_err != ErrorType.SUCCESS: + res.result.SetResult(exist_err) + return res + already_set = set(already) + to_create = [iid for iid in matched_item_ids if iid not in already_set] + res.skipped_count = len(matched_item_ids) - len(to_create) + if not to_create: + return res + + mappings = [supplier_items(supplier_id=supplier_uuid, item_id=iid, supply_type=0) for iid in to_create] + err_type = await DB_SESSION_MNG.execute_lambda_run( + [supplier_items.DBType()], + [lambda s: self.supplier_item_crud.add_many(s, mappings)], + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + res.created_count = len(to_create) + return res diff --git a/negodata/front/src/api/generated/model/index.ts b/negodata/front/src/api/generated/model/index.ts index 91d6398..0a19228 100644 --- a/negodata/front/src/api/generated/model/index.ts +++ b/negodata/front/src/api/generated/model/index.ts @@ -69,6 +69,7 @@ export * from './itemDataSellingPrice'; export * from './itemDataSpec'; export * from './itemDataUpdatedAt'; export * from './itemDataVatYn'; +export * from './itemSupplyType'; export * from './listCardsParams'; export * from './listItemsParams'; export * from './listNotificationsParams'; @@ -119,6 +120,7 @@ export * from './quotationSettingDataUpdatedAt'; export * from './quotationSettingDataUserId'; export * from './quotationStatus'; export * from './quotationType'; +export * from './reqBulkMapByNames'; export * from './reqCheckCodes'; export * from './reqCreateCard'; export * from './reqCreateCardCondition'; @@ -160,6 +162,7 @@ export * from './reqCreateQuotationSupplierType'; export * from './reqCreateQuotationVersionId'; export * from './reqCreateSupplier'; export * from './reqCreateSupplierCode'; +export * from './reqCreateSupplierItem'; export * from './reqCreateSupplierManagerContactNumber'; export * from './reqCreateSupplierManagerEmail'; export * from './reqCreateSupplierManagerName'; @@ -217,6 +220,9 @@ export * from './reqUpdateSupplierManagerEmail'; export * from './reqUpdateSupplierManagerName'; export * from './reqUpdateSupplierName'; export * from './reqUpdateSupplierTotalRevenue'; +export * from './reqUpdateSupplyType'; +export * from './resBulkMapByNames'; +export * from './resBulkMapByNamesMsg'; export * from './resCard'; export * from './resCardCard'; export * from './resCardList'; @@ -258,6 +264,8 @@ export * from './resItemItem'; export * from './resItemList'; export * from './resItemListMsg'; export * from './resItemMsg'; +export * from './resItemSupplyTypeList'; +export * from './resItemSupplyTypeListMsg'; export * from './resLastSupplierType'; export * from './resLastSupplierTypeMsg'; export * from './resLastSupplierTypeQtNumber'; @@ -312,6 +320,11 @@ export * from './resSessionChat'; export * from './resSessionChatMsg'; export * from './resSessionChatSessionId'; export * from './resSupplier'; +export * from './resSupplierItem'; +export * from './resSupplierItemList'; +export * from './resSupplierItemListMsg'; +export * from './resSupplierItemMsg'; +export * from './resSupplierItemSupplierItem'; export * from './resSupplierList'; export * from './resSupplierListMsg'; export * from './resSupplierMsg'; @@ -324,6 +337,8 @@ export * from './resTargetBreakdownMdPrice'; export * from './resTargetBreakdownMsg'; export * from './resTargetBreakdownPurchase'; export * from './resTargetBreakdownSelling'; +export * from './resWebPacketProtocol'; +export * from './resWebPacketProtocolMsg'; export * from './sessionData'; export * from './sessionDataAnchoringPrice'; export * from './sessionDataBidAt'; @@ -341,6 +356,10 @@ export * from './supplierDataManagerEmail'; export * from './supplierDataManagerName'; export * from './supplierDataTotalRevenue'; export * from './supplierDataUpdatedAt'; +export * from './supplierItemData'; +export * from './supplierItemDataCreatedAt'; +export * from './supplierItemDataItemCode'; +export * from './supplierItemDataUpdatedAt'; export * from './supplierType'; export * from './targetCandidate'; export * from './userRole'; diff --git a/negodata/front/src/api/generated/model/itemSupplyType.ts b/negodata/front/src/api/generated/model/itemSupplyType.ts new file mode 100644 index 0000000..47c4381 --- /dev/null +++ b/negodata/front/src/api/generated/model/itemSupplyType.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export interface ItemSupplyType { + supplier_id: string; + supply_type: number; +} diff --git a/negodata/front/src/api/generated/model/reqBulkMapByNames.ts b/negodata/front/src/api/generated/model/reqBulkMapByNames.ts new file mode 100644 index 0000000..3416e10 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqBulkMapByNames.ts @@ -0,0 +1,10 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export interface ReqBulkMapByNames { + names?: string[]; +} diff --git a/negodata/front/src/api/generated/model/reqCreateSupplierItem.ts b/negodata/front/src/api/generated/model/reqCreateSupplierItem.ts new file mode 100644 index 0000000..5caadac --- /dev/null +++ b/negodata/front/src/api/generated/model/reqCreateSupplierItem.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export interface ReqCreateSupplierItem { + supplier_id: string; + item_id: string; + supply_type?: number; +} diff --git a/negodata/front/src/api/generated/model/reqUpdateSupplyType.ts b/negodata/front/src/api/generated/model/reqUpdateSupplyType.ts new file mode 100644 index 0000000..a2014a8 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateSupplyType.ts @@ -0,0 +1,10 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export interface ReqUpdateSupplyType { + supply_type: number; +} diff --git a/negodata/front/src/api/generated/model/resBulkMapByNames.ts b/negodata/front/src/api/generated/model/resBulkMapByNames.ts new file mode 100644 index 0000000..bac1fe0 --- /dev/null +++ b/negodata/front/src/api/generated/model/resBulkMapByNames.ts @@ -0,0 +1,16 @@ +/** + * 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 { ResBulkMapByNamesMsg } from './resBulkMapByNamesMsg'; + +export interface ResBulkMapByNames { + result?: ErrorInfo; + msg?: ResBulkMapByNamesMsg; + created_count?: number; + skipped_count?: number; + unmatched?: string[]; +} diff --git a/negodata/front/src/api/generated/model/resBulkMapByNamesMsg.ts b/negodata/front/src/api/generated/model/resBulkMapByNamesMsg.ts new file mode 100644 index 0000000..332ba39 --- /dev/null +++ b/negodata/front/src/api/generated/model/resBulkMapByNamesMsg.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 ResBulkMapByNamesMsg = string | null; diff --git a/negodata/front/src/api/generated/model/resItemSupplyTypeList.ts b/negodata/front/src/api/generated/model/resItemSupplyTypeList.ts new file mode 100644 index 0000000..6ed52b8 --- /dev/null +++ b/negodata/front/src/api/generated/model/resItemSupplyTypeList.ts @@ -0,0 +1,15 @@ +/** + * 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 { ResItemSupplyTypeListMsg } from './resItemSupplyTypeListMsg'; +import type { ItemSupplyType } from './itemSupplyType'; + +export interface ResItemSupplyTypeList { + result?: ErrorInfo; + msg?: ResItemSupplyTypeListMsg; + suppliers?: ItemSupplyType[]; +} diff --git a/negodata/front/src/api/generated/model/resItemSupplyTypeListMsg.ts b/negodata/front/src/api/generated/model/resItemSupplyTypeListMsg.ts new file mode 100644 index 0000000..1c7abd5 --- /dev/null +++ b/negodata/front/src/api/generated/model/resItemSupplyTypeListMsg.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 ResItemSupplyTypeListMsg = string | null; diff --git a/negodata/front/src/api/generated/model/resSupplierItem.ts b/negodata/front/src/api/generated/model/resSupplierItem.ts new file mode 100644 index 0000000..a6c22ad --- /dev/null +++ b/negodata/front/src/api/generated/model/resSupplierItem.ts @@ -0,0 +1,15 @@ +/** + * 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 { ResSupplierItemMsg } from './resSupplierItemMsg'; +import type { ResSupplierItemSupplierItem } from './resSupplierItemSupplierItem'; + +export interface ResSupplierItem { + result?: ErrorInfo; + msg?: ResSupplierItemMsg; + supplier_item?: ResSupplierItemSupplierItem; +} diff --git a/negodata/front/src/api/generated/model/resSupplierItemList.ts b/negodata/front/src/api/generated/model/resSupplierItemList.ts new file mode 100644 index 0000000..00f06e8 --- /dev/null +++ b/negodata/front/src/api/generated/model/resSupplierItemList.ts @@ -0,0 +1,15 @@ +/** + * 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 { ResSupplierItemListMsg } from './resSupplierItemListMsg'; +import type { SupplierItemData } from './supplierItemData'; + +export interface ResSupplierItemList { + result?: ErrorInfo; + msg?: ResSupplierItemListMsg; + supplier_items?: SupplierItemData[]; +} diff --git a/negodata/front/src/api/generated/model/resSupplierItemListMsg.ts b/negodata/front/src/api/generated/model/resSupplierItemListMsg.ts new file mode 100644 index 0000000..0657757 --- /dev/null +++ b/negodata/front/src/api/generated/model/resSupplierItemListMsg.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 ResSupplierItemListMsg = string | null; diff --git a/negodata/front/src/api/generated/model/resSupplierItemMsg.ts b/negodata/front/src/api/generated/model/resSupplierItemMsg.ts new file mode 100644 index 0000000..8e41756 --- /dev/null +++ b/negodata/front/src/api/generated/model/resSupplierItemMsg.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 ResSupplierItemMsg = string | null; diff --git a/negodata/front/src/api/generated/model/resSupplierItemSupplierItem.ts b/negodata/front/src/api/generated/model/resSupplierItemSupplierItem.ts new file mode 100644 index 0000000..b6440d8 --- /dev/null +++ b/negodata/front/src/api/generated/model/resSupplierItemSupplierItem.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { SupplierItemData } from './supplierItemData'; + +export type ResSupplierItemSupplierItem = SupplierItemData | null; diff --git a/negodata/front/src/api/generated/model/resWebPacketProtocol.ts b/negodata/front/src/api/generated/model/resWebPacketProtocol.ts new file mode 100644 index 0000000..b5909cd --- /dev/null +++ b/negodata/front/src/api/generated/model/resWebPacketProtocol.ts @@ -0,0 +1,13 @@ +/** + * 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 { ResWebPacketProtocolMsg } from './resWebPacketProtocolMsg'; + +export interface ResWebPacketProtocol { + result?: ErrorInfo; + msg?: ResWebPacketProtocolMsg; +} diff --git a/negodata/front/src/api/generated/model/resWebPacketProtocolMsg.ts b/negodata/front/src/api/generated/model/resWebPacketProtocolMsg.ts new file mode 100644 index 0000000..b5db7ea --- /dev/null +++ b/negodata/front/src/api/generated/model/resWebPacketProtocolMsg.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 ResWebPacketProtocolMsg = string | null; diff --git a/negodata/front/src/api/generated/model/supplierItemData.ts b/negodata/front/src/api/generated/model/supplierItemData.ts new file mode 100644 index 0000000..2912517 --- /dev/null +++ b/negodata/front/src/api/generated/model/supplierItemData.ts @@ -0,0 +1,19 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { SupplierItemDataItemCode } from './supplierItemDataItemCode'; +import type { SupplierItemDataCreatedAt } from './supplierItemDataCreatedAt'; +import type { SupplierItemDataUpdatedAt } from './supplierItemDataUpdatedAt'; + +export interface SupplierItemData { + supplier_item_id: string; + item_id: string; + item_name: string; + item_code?: SupplierItemDataItemCode; + supply_type: number; + created_at?: SupplierItemDataCreatedAt; + updated_at?: SupplierItemDataUpdatedAt; +} diff --git a/negodata/front/src/api/generated/model/supplierItemDataCreatedAt.ts b/negodata/front/src/api/generated/model/supplierItemDataCreatedAt.ts new file mode 100644 index 0000000..0deab85 --- /dev/null +++ b/negodata/front/src/api/generated/model/supplierItemDataCreatedAt.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 SupplierItemDataCreatedAt = string | null; diff --git a/negodata/front/src/api/generated/model/supplierItemDataItemCode.ts b/negodata/front/src/api/generated/model/supplierItemDataItemCode.ts new file mode 100644 index 0000000..7f261ff --- /dev/null +++ b/negodata/front/src/api/generated/model/supplierItemDataItemCode.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 SupplierItemDataItemCode = string | null; diff --git a/negodata/front/src/api/generated/model/supplierItemDataUpdatedAt.ts b/negodata/front/src/api/generated/model/supplierItemDataUpdatedAt.ts new file mode 100644 index 0000000..1fcc39f --- /dev/null +++ b/negodata/front/src/api/generated/model/supplierItemDataUpdatedAt.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 SupplierItemDataUpdatedAt = string | null; diff --git a/negodata/front/src/api/generated/supplier-item/supplier-item.ts b/negodata/front/src/api/generated/supplier-item/supplier-item.ts new file mode 100644 index 0000000..ecd6a25 --- /dev/null +++ b/negodata/front/src/api/generated/supplier-item/supplier-item.ts @@ -0,0 +1,483 @@ +/** + * 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, + ReqBulkMapByNames, + ReqCreateSupplierItem, + ReqUpdateSupplyType, + ResBulkMapByNames, + ResItemSupplyTypeList, + ResSupplierItem, + ResSupplierItemList, + ResWebPacketProtocol +} from '.././model'; + +import { customFetch } from '../../mutator/custom-fetch'; + + +type SecondParameter unknown> = Parameters[1]; + + + +/** + * @summary 협력사 취급상품 목록 + */ +export const listSupplierItems = ( + supplierId: string, + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/supplier-item/by-supplier/${supplierId}`, method: 'GET', signal + }, + options); + } + + + + +export const getListSupplierItemsQueryKey = (supplierId?: string,) => { + return [ + `/v1/supplier-item/by-supplier/${supplierId}` + ] as const; + } + + +export const getListSupplierItemsQueryOptions = >, TError = void | HTTPValidationError>(supplierId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getListSupplierItemsQueryKey(supplierId); + + + + const queryFn: QueryFunction>> = ({ signal }) => listSupplierItems(supplierId, requestOptions, signal); + + + + + + return { queryKey, queryFn, enabled: !!(supplierId), ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type ListSupplierItemsQueryResult = NonNullable>> +export type ListSupplierItemsQueryError = void | HTTPValidationError + + +export function useListSupplierItems>, TError = void | HTTPValidationError>( + supplierId: string, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useListSupplierItems>, TError = void | HTTPValidationError>( + supplierId: string, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useListSupplierItems>, TError = void | HTTPValidationError>( + supplierId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary 협력사 취급상품 목록 + */ + +export function useListSupplierItems>, TError = void | HTTPValidationError>( + supplierId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getListSupplierItemsQueryOptions(supplierId,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey ; + + return query; +} + + + + +/** + * @summary 상품 취급 협력사 공급유형 목록 + */ +export const listItemSupplyTypes = ( + itemId: string, + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/supplier-item/by-item/${itemId}`, method: 'GET', signal + }, + options); + } + + + + +export const getListItemSupplyTypesQueryKey = (itemId?: string,) => { + return [ + `/v1/supplier-item/by-item/${itemId}` + ] as const; + } + + +export const getListItemSupplyTypesQueryOptions = >, TError = void | HTTPValidationError>(itemId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getListItemSupplyTypesQueryKey(itemId); + + + + const queryFn: QueryFunction>> = ({ signal }) => listItemSupplyTypes(itemId, requestOptions, signal); + + + + + + return { queryKey, queryFn, enabled: !!(itemId), ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type ListItemSupplyTypesQueryResult = NonNullable>> +export type ListItemSupplyTypesQueryError = void | HTTPValidationError + + +export function useListItemSupplyTypes>, TError = void | HTTPValidationError>( + itemId: string, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useListItemSupplyTypes>, TError = void | HTTPValidationError>( + itemId: string, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useListItemSupplyTypes>, TError = void | HTTPValidationError>( + itemId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary 상품 취급 협력사 공급유형 목록 + */ + +export function useListItemSupplyTypes>, TError = void | HTTPValidationError>( + itemId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getListItemSupplyTypesQueryOptions(itemId,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey ; + + return query; +} + + + + +/** + * @summary 취급상품 매핑 추가 + */ +export const createSupplierItem = ( + reqCreateSupplierItem: ReqCreateSupplierItem, + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/supplier-item/create`, method: 'POST', + headers: {'Content-Type': 'application/json', }, + data: reqCreateSupplierItem, signal + }, + options); + } + + + +export const getCreateSupplierItemMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: ReqCreateSupplierItem}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{data: ReqCreateSupplierItem}, TContext> => { + +const mutationKey = ['createSupplierItem']; +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>, {data: ReqCreateSupplierItem}> = (props) => { + const {data} = props ?? {}; + + return createSupplierItem(data,requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type CreateSupplierItemMutationResult = NonNullable>> + export type CreateSupplierItemMutationBody = ReqCreateSupplierItem + export type CreateSupplierItemMutationError = void | HTTPValidationError + + /** + * @summary 취급상품 매핑 추가 + */ +export const useCreateSupplierItem = (options?: { mutation?:UseMutationOptions>, TError,{data: ReqCreateSupplierItem}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {data: ReqCreateSupplierItem}, + TContext + > => { + + const mutationOptions = getCreateSupplierItemMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } + /** + * @summary 취급상품 이름 일괄 매핑(엑셀 업로드) + */ +export const bulkMapSupplierItems = ( + supplierId: string, + reqBulkMapByNames: ReqBulkMapByNames, + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/supplier-item/by-supplier/${supplierId}/bulk`, method: 'POST', + headers: {'Content-Type': 'application/json', }, + data: reqBulkMapByNames, signal + }, + options); + } + + + +export const getBulkMapSupplierItemsMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{supplierId: string;data: ReqBulkMapByNames}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{supplierId: string;data: ReqBulkMapByNames}, TContext> => { + +const mutationKey = ['bulkMapSupplierItems']; +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>, {supplierId: string;data: ReqBulkMapByNames}> = (props) => { + const {supplierId,data} = props ?? {}; + + return bulkMapSupplierItems(supplierId,data,requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type BulkMapSupplierItemsMutationResult = NonNullable>> + export type BulkMapSupplierItemsMutationBody = ReqBulkMapByNames + export type BulkMapSupplierItemsMutationError = void | HTTPValidationError + + /** + * @summary 취급상품 이름 일괄 매핑(엑셀 업로드) + */ +export const useBulkMapSupplierItems = (options?: { mutation?:UseMutationOptions>, TError,{supplierId: string;data: ReqBulkMapByNames}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {supplierId: string;data: ReqBulkMapByNames}, + TContext + > => { + + const mutationOptions = getBulkMapSupplierItemsMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } + /** + * @summary 취급상품 공급유형 수정 + */ +export const updateSupplyType = ( + supplierItemId: string, + reqUpdateSupplyType: ReqUpdateSupplyType, + options?: SecondParameter,) => { + + + return customFetch( + {url: `/v1/supplier-item/update/${supplierItemId}`, method: 'PATCH', + headers: {'Content-Type': 'application/json', }, + data: reqUpdateSupplyType + }, + options); + } + + + +export const getUpdateSupplyTypeMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{supplierItemId: string;data: ReqUpdateSupplyType}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{supplierItemId: string;data: ReqUpdateSupplyType}, TContext> => { + +const mutationKey = ['updateSupplyType']; +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>, {supplierItemId: string;data: ReqUpdateSupplyType}> = (props) => { + const {supplierItemId,data} = props ?? {}; + + return updateSupplyType(supplierItemId,data,requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type UpdateSupplyTypeMutationResult = NonNullable>> + export type UpdateSupplyTypeMutationBody = ReqUpdateSupplyType + export type UpdateSupplyTypeMutationError = void | HTTPValidationError + + /** + * @summary 취급상품 공급유형 수정 + */ +export const useUpdateSupplyType = (options?: { mutation?:UseMutationOptions>, TError,{supplierItemId: string;data: ReqUpdateSupplyType}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {supplierItemId: string;data: ReqUpdateSupplyType}, + TContext + > => { + + const mutationOptions = getUpdateSupplyTypeMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } + /** + * @summary 취급상품 매핑 삭제 + */ +export const deleteSupplierItem = ( + supplierItemId: string, + options?: SecondParameter,) => { + + + return customFetch( + {url: `/v1/supplier-item/delete/${supplierItemId}`, method: 'DELETE' + }, + options); + } + + + +export const getDeleteSupplierItemMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{supplierItemId: string}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{supplierItemId: string}, TContext> => { + +const mutationKey = ['deleteSupplierItem']; +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>, {supplierItemId: string}> = (props) => { + const {supplierItemId} = props ?? {}; + + return deleteSupplierItem(supplierItemId,requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type DeleteSupplierItemMutationResult = NonNullable>> + + export type DeleteSupplierItemMutationError = void | HTTPValidationError + + /** + * @summary 취급상품 매핑 삭제 + */ +export const useDeleteSupplierItem = (options?: { mutation?:UseMutationOptions>, TError,{supplierItemId: string}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {supplierItemId: string}, + TContext + > => { + + const mutationOptions = getDeleteSupplierItemMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } + \ No newline at end of file diff --git a/negodata/front/src/components/ui/combobox.tsx b/negodata/front/src/components/ui/combobox.tsx new file mode 100644 index 0000000..a2c84dd --- /dev/null +++ b/negodata/front/src/components/ui/combobox.tsx @@ -0,0 +1,178 @@ +import { useEffect, useRef, useState, type ReactNode } from 'react'; +import { Search, Check, Loader2, ChevronsUpDown } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Input } from './input'; +import { Typography } from './typography'; + +// 서버검색 콤보박스 — 목록(options)은 부모가 쿼리에 맞춰 조회해 넘기고, 검색어 디바운스는 이 컴포넌트가 처리한다. +// variant: 'field'(트리거+팝오버, 단일선택 폼용) / 'inline'(검색창+리스트 상시노출, 다중 체크리스트용). +export type ComboOption = { + id: string; + label: string; // 검색결과에 없을 때 선택 표시용 텍스트 + node?: ReactNode; // 커스텀 행(미지정 시 label 렌더) + disabled?: boolean; +}; + +type ComboboxProps = { + options: ComboOption[]; + onQueryChange: (q: string) => void; // 내부 디바운스 후 호출 + loading?: boolean; + placeholder?: string; + searchPlaceholder?: string; + emptyText?: string; + debounceMs?: number; + id?: string; + className?: string; + maxListHeight?: string; // tailwind, default max-h-56 + variant?: 'field' | 'inline'; + multiple?: boolean; + // single + value?: string; + selectedLabel?: ReactNode; + onSelect?: (opt: ComboOption) => void; + // multi + values?: string[]; + onToggle?: (opt: ComboOption) => void; +}; + +export function Combobox({ + options, + onQueryChange, + loading, + placeholder = '선택...', + searchPlaceholder = '검색...', + emptyText = '결과가 없습니다', + debounceMs = 300, + id, + className, + maxListHeight = 'max-h-56', + variant = 'field', + multiple = false, + value, + selectedLabel, + onSelect, + values = [], + onToggle, +}: ComboboxProps) { + const [text, setText] = useState(''); + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + + // 검색어 디바운스 → onQueryChange. 콜백은 ref로 잡아 text 변화에만 반응. + const qcRef = useRef(onQueryChange); + qcRef.current = onQueryChange; + useEffect(() => { + const t = setTimeout(() => qcRef.current(text.trim()), debounceMs); + return () => clearTimeout(t); + }, [text, debounceMs]); + + // field 팝오버 바깥 클릭 시 닫기. + useEffect(() => { + if (variant === 'inline') return; + const onDoc = (e: MouseEvent) => { + if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener('mousedown', onDoc); + return () => document.removeEventListener('mousedown', onDoc); + }, [variant]); + + const isSelected = (oid: string) => (multiple ? values.includes(oid) : value === oid); + + const handlePick = (opt: ComboOption) => { + if (opt.disabled) return; + if (multiple) onToggle?.(opt); + else { + onSelect?.(opt); + setOpen(false); + } + }; + + const searchInput = ( +
+ + setText(e.target.value)} + placeholder={searchPlaceholder} + className="pl-7 text-xs" + autoComplete="off" + /> + {loading && } +
+ ); + + const list = ( +
+ {loading && options.length === 0 ? ( +
+ + 불러오는 중… +
+ ) : options.length === 0 ? ( + {emptyText} + ) : ( + options.map((opt) => { + const sel = isSelected(opt.id); + return ( + + ); + }) + )} +
+ ); + + if (variant === 'inline') { + return ( +
+ {searchInput} + {list} +
+ ); + } + + // field: 트리거(선택 요약) + 팝오버(검색창 + 리스트) + const hasSelection = multiple ? values.length > 0 : !!value; + const summary = multiple + ? (values.length ? `${values.length}개 선택됨` : placeholder) + : (value ? selectedLabel ?? '선택됨' : placeholder); + + return ( +
+ + {open && ( +
+ {searchInput} + {list} +
+ )} +
+ ); +} diff --git a/negodata/front/src/features/partners/components/ExcelUploadModal.tsx b/negodata/front/src/features/partners/components/ExcelUploadModal.tsx index 2a9e4e3..3d0dd32 100644 --- a/negodata/front/src/features/partners/components/ExcelUploadModal.tsx +++ b/negodata/front/src/features/partners/components/ExcelUploadModal.tsx @@ -18,17 +18,26 @@ type RawRow = { managerName: string; managerEmail: string; totalRevenue: string; + products: string; // 취급상품 — 상품명 콤마(,) 나열. 업로드 시 매핑테이블로 들어간다. }; type ValidatedRow = RawRow & { status: '정상' | '오류'; message: string }; // 업로드 양식 한 줄(예시 행) -type TemplateRow = { name: string; code: string; managerName: string; managerEmail: string; totalRevenue: string }; +type TemplateRow = { name: string; code: string; managerName: string; managerEmail: string; totalRevenue: string; products: string }; + +// 협력사 1건 + 그 협력사에 매핑할 취급상품명 리스트. +export type PartnerUploadRow = { supplier: SupplierCreate; products: string[] }; +// 일괄 등록 결과 — 협력사 등록 실패(행별) + 이름 미매칭으로 건너뛴 취급상품명들. +export type PartnerBulkResult = { failures: BulkFailure[]; unmatchedProducts: string[] }; + +// "상품A, 상품B" → ['상품A','상품B'] (공백/빈값 제거). +const splitNames = (s: string): string[] => s.split(',').map((t) => t.trim()).filter(Boolean); type ExcelUploadModalProps = { open: boolean; partners: Partner[]; // 코드 중복 검사용 - onConfirm: (rows: SupplierCreate[]) => Promise; + onConfirm: (rows: PartnerUploadRow[]) => Promise; onClose: () => void; }; @@ -82,8 +91,9 @@ export function downloadPartnerTemplate() { { header: '담당자명', value: (r) => r.managerName }, { header: '담당자이메일', value: (r) => r.managerEmail }, { header: '총매출액', value: (r) => r.totalRevenue }, + { header: '취급상품', value: (r) => r.products }, ], - [{ name: '예시) (주)한빛정밀', code: 'PART-EXAMPLE-001', managerName: '김철수 과장', managerEmail: 'cs.kim@example.com', totalRevenue: '5000000000' }], + [{ name: '예시) (주)한빛정밀', code: 'PART-EXAMPLE-001', managerName: '김철수 과장', managerEmail: 'cs.kim@example.com', totalRevenue: '5000000000', products: '고압 에어 컴프레서, 스테인리스 볼밸브' }], ); } @@ -124,6 +134,7 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp managerName: r['담당자명'] ?? '', managerEmail: r['담당자이메일'] ?? '', totalRevenue: r['총매출액'] ?? '', + products: r['취급상품'] ?? '', })); setExcelFile(file.name); setRows(loaded); @@ -147,7 +158,7 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp }; // 인라인 편집 — 원본 필드만 갱신(재검증은 파생이 처리) - const handleUpdateField = (id: string, field: 'name' | 'code' | 'managerName' | 'managerEmail', value: string) => { + const handleUpdateField = (id: string, field: 'name' | 'code' | 'managerName' | 'managerEmail' | 'products', value: string) => { setRows((cur) => cur.map((row) => (row.id === id ? { ...row, [field]: value } : row))); }; @@ -162,10 +173,16 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp return; } try { - const failures = await onConfirm(validRows.map(toSupplierCreate)); + const { failures, unmatchedProducts } = await onConfirm( + validRows.map((r) => ({ supplier: toSupplierCreate(r), products: splitNames(r.products) })), + ); const okCount = validRows.length - failures.length; + // 이름이 회사 상품목록에 없어 매핑 못한 취급상품 — 스킵하고 결과에 부기(결정: 미매칭은 스킵+리포트). + const unmatchedNote = unmatchedProducts.length + ? ` · 미매칭 취급상품 ${unmatchedProducts.length}건 건너뜀(${unmatchedProducts.slice(0, 5).join(', ')}${unmatchedProducts.length > 5 ? '…' : ''})` + : ''; if (failures.length === 0) { - showToast(`총 ${okCount}개 협력사가 서버에 일괄 등록되었습니다.`, 'success'); + showToast(`총 ${okCount}개 협력사가 서버에 일괄 등록되었습니다.${unmatchedNote}`, unmatchedProducts.length ? 'info' : 'success'); close(); return; } @@ -175,7 +192,7 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp const okCodes = new Set(validRows.map((r) => r.code).filter((c) => failMap[c] === undefined)); setServerErrors(failMap); setRows((cur) => cur.filter((r) => !okCodes.has(r.code))); - showToast(`${okCount}건 등록 완료 · ${failures.length}건 서버 검증 실패(중복코드 등)`, 'error'); + showToast(`${okCount}건 등록 완료 · ${failures.length}건 서버 검증 실패(중복코드 등)${unmatchedNote}`, 'error'); } catch (err) { showToast(err instanceof Error ? err.message : '엑셀 일괄 등록 실패', 'error'); } @@ -271,6 +288,7 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp 협력사코드 * 담당자명 * 담당자 이메일 * + 취급상품 (,로 구분) @@ -331,6 +349,15 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp onChange={(e) => handleUpdateField(row.id, 'managerEmail', e.target.value)} /> + + handleUpdateField(row.id, 'products', e.target.value)} + placeholder="상품명, 상품명" + /> + ))} diff --git a/negodata/front/src/features/partners/components/PartnerFormSheet.tsx b/negodata/front/src/features/partners/components/PartnerFormSheet.tsx index 2c27569..173d254 100644 --- a/negodata/front/src/features/partners/components/PartnerFormSheet.tsx +++ b/negodata/front/src/features/partners/components/PartnerFormSheet.tsx @@ -9,6 +9,7 @@ import { Typography } from '@/components/ui/typography'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Sheet } from '@/components/ui/sheet'; +import { SupplierItemsManager } from './SupplierItemsManager'; import { type Partner } from '../types'; const schema = z.object({ @@ -189,6 +190,9 @@ export function PartnerFormSheet({ {errors.managerPhone &&

{errors.managerPhone.message}

} + {/* 취급상품 관리 — 수정 모드(협력사 확정)에서만. 추가/삭제/유형변경은 즉시 서버 반영. */} + {mode === 'edit' && partner && } + {/* Buttons wrapper */}
{mode === 'edit' && partner && ( diff --git a/negodata/front/src/features/partners/components/SupplierItemsManager.tsx b/negodata/front/src/features/partners/components/SupplierItemsManager.tsx new file mode 100644 index 0000000..671b2ea --- /dev/null +++ b/negodata/front/src/features/partners/components/SupplierItemsManager.tsx @@ -0,0 +1,147 @@ +import { useState } from 'react'; +import { Plus, Trash2 } from 'lucide-react'; +import { useListItems } from '@/api/generated/item/item'; +import { SupplierType } from '@/api/generated/model'; +import { Typography } from '@/components/ui/typography'; +import { Button } from '@/components/ui/button'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Combobox, type ComboOption } from '@/components/ui/combobox'; +import { SUPPLIER_TYPE_OPTIONS, supplierTypeLabel } from '@/lib/enumLabels'; +import { showToast } from '@/lib/notify'; +import { useSupplierItems } from '../hooks/useSupplierItems'; + +// 협력사 상세의 취급상품 관리 섹션 — 상품 추가/삭제 + 공급유형(제조/유통/총판/없음) 수정. +// 각 조작은 즉시 서버 반영(협력사 기본정보 저장과 독립). +export function SupplierItemsManager({ supplierId }: { supplierId: string }) { + const { items, isLoading, addItem, changeType, removeItem } = useSupplierItems(supplierId); + const [q, setQ] = useState(''); + const catalogQuery = useListItems({ search: q || undefined, size: 30 }); // 서버검색(상품 100개 이상도 검색으로 도달) + + const [pickItemId, setPickItemId] = useState(''); + const [pickLabel, setPickLabel] = useState(''); + const [pickType, setPickType] = useState(String(SupplierType.NONE)); // 기본 없음(0) + const [busy, setBusy] = useState(false); + + const mappedIds = new Set(items.map((m) => m.item_id)); + const options: ComboOption[] = (catalogQuery.data?.items ?? []) + .filter((it) => !mappedIds.has(it.item_id)) + .map((it) => ({ id: it.item_id, label: `${it.name}${it.code ? ` [${it.code}]` : ''}` })); + + const handleAdd = async () => { + if (!pickItemId) { + showToast('추가할 상품을 선택하세요.', 'error'); + return; + } + setBusy(true); + try { + await addItem(pickItemId, Number(pickType)); + setPickItemId(''); + setPickLabel(''); + setQ(''); + showToast('취급상품이 추가되었습니다.', 'success'); + } catch (err) { + showToast(err instanceof Error ? err.message : '취급상품 추가 실패', 'error'); + } finally { + setBusy(false); + } + }; + + const handleChangeType = async (supplierItemId: string, v: string) => { + try { + await changeType(supplierItemId, Number(v)); + } catch { + showToast('공급유형 변경에 실패했습니다.', 'error'); + } + }; + + const handleRemove = async (supplierItemId: string) => { + try { + await removeItem(supplierItemId); + showToast('취급상품이 삭제되었습니다.', 'info'); + } catch { + showToast('취급상품 삭제에 실패했습니다.', 'error'); + } + }; + + return ( +
+ 취급상품 ({items.length}) + + {/* 추가 행 — 상품 + 공급유형 선택 후 추가 */} +
+
+ { setPickItemId(opt.id); setPickLabel(opt.label); }} + placeholder="취급상품으로 추가할 상품 검색..." + searchPlaceholder="상품명·코드로 검색..." + emptyText="일치하는 상품이 없습니다" + /> +
+
+ +
+ +
+ + {/* 현재 취급상품 목록 */} +
+ {isLoading ? ( + 불러오는 중… + ) : items.length === 0 ? ( + 등록된 취급상품이 없습니다. + ) : ( + items.map((m) => ( +
+
+ {m.item_name} + {m.item_code && ( + {m.item_code} + )} +
+
+
+ +
+ +
+
+ )) + )} +
+
+ ); +} diff --git a/negodata/front/src/features/partners/hooks/usePartners.ts b/negodata/front/src/features/partners/hooks/usePartners.ts index 34beaed..ac89c1f 100644 --- a/negodata/front/src/features/partners/hooks/usePartners.ts +++ b/negodata/front/src/features/partners/hooks/usePartners.ts @@ -5,12 +5,14 @@ import { updateSupplier, deleteSupplier, } from '@/api/generated/supplier/supplier'; +import { bulkMapSupplierItems } from '@/api/generated/supplier-item/supplier-item'; import type { ListSuppliersParams } from '@/api/generated/model/listSuppliersParams'; import type { ReqCreateSupplier } from '@/api/generated/model/reqCreateSupplier'; import type { ReqUpdateSupplier } from '@/api/generated/model/reqUpdateSupplier'; import type { ResSupplier } from '@/api/generated/model/resSupplier'; import type { SupplierData } from '@/api/generated/model/supplierData'; import type { BulkFailure } from '@/lib/excel'; +import type { PartnerUploadRow, PartnerBulkResult } from '../components/ExcelUploadModal'; import type { Partner } from '../types'; // 엑셀 중복검사 모달이 참조하는 "전체 협력사"용 메타 쿼리(최대 100건). @@ -54,18 +56,33 @@ export function usePartners(params: ListSuppliersParams) { }; // 엑셀 일괄 등록 — 행별로 순차 생성하되 실패해도 멈추지 않고 사유를 모은다. // 서버 DB 검증(중복코드 등)에 걸린 행은 BulkFailure 로 반환 → 모달이 해당 행만 사유와 함께 남긴다. - const bulkCreate = async (rows: ReqCreateSupplier[]): Promise => { + // 등록 성공 시 취급상품(상품명 리스트)을 매핑테이블로 밀어넣는다. 이름 미매칭분은 서버가 스킵하고 돌려줘 리포트한다. + const bulkCreate = async (rows: PartnerUploadRow[]): Promise => { const failures: BulkFailure[] = []; - for (const row of rows) { + const unmatched = new Set(); + for (const { supplier, products } of rows) { try { - const msg = supplierError(await createSupplier(row)); - if (msg) failures.push({ code: row.code ?? '', message: msg }); + const res = await createSupplier(supplier); + const msg = supplierError(res); + if (msg) { + failures.push({ code: supplier.code ?? '', message: msg }); + continue; + } + const newId = res.supplier?.supplier_id; + if (newId && products.length) { + try { + const mapRes = await bulkMapSupplierItems(newId, { names: products }); + (mapRes.unmatched ?? []).forEach((n) => unmatched.add(n)); + } catch { + // 취급상품 매핑 실패는 협력사 등록 자체를 되돌리지 않는다(등록은 성공 처리). + } + } } catch (err) { - failures.push({ code: row.code ?? '', message: err instanceof Error ? err.message : '등록 실패' }); + failures.push({ code: supplier.code ?? '', message: err instanceof Error ? err.message : '등록 실패' }); } } await refresh(); - return failures; + return { failures, unmatchedProducts: [...unmatched] }; }; // 테이블(현재 페이지) 협력사 + 서버 전체 건수. diff --git a/negodata/front/src/features/partners/hooks/useSupplierItems.ts b/negodata/front/src/features/partners/hooks/useSupplierItems.ts new file mode 100644 index 0000000..a5bc7b3 --- /dev/null +++ b/negodata/front/src/features/partners/hooks/useSupplierItems.ts @@ -0,0 +1,45 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { + useListSupplierItems, + createSupplierItem, + updateSupplyType, + deleteSupplierItem, + getListSupplierItemsQueryKey, +} from '@/api/generated/supplier-item/supplier-item'; +import type { SupplierItemData } from '@/api/generated/model/supplierItemData'; + +// 협력사 취급상품(매핑) 서버 데이터 + CRUD. 협력사 상세(PartnerFormSheet)에서 쓴다. +// supplierId 가 없으면(신규 등록 폼) 쿼리는 비활성. +export function useSupplierItems(supplierId: string | undefined) { + const queryClient = useQueryClient(); + const listQuery = useListSupplierItems(supplierId ?? '', { query: { enabled: !!supplierId } }); + + const refresh = () => + supplierId + ? queryClient.invalidateQueries({ queryKey: getListSupplierItemsQueryKey(supplierId) }) + : Promise.resolve(); + + const items: SupplierItemData[] = listQuery.data?.supplier_items ?? []; + + const addItem = async (itemId: string, supplyType: number) => { + if (!supplierId) return; + const res = await createSupplierItem({ supplier_id: supplierId, item_id: itemId, supply_type: supplyType }); + const r = res.result; + if (r && r.success === false) { + throw new Error(r.desc === 'DB_ALREADY_SAME_KEY' ? '이미 등록된 취급상품입니다.' : r.desc || '취급상품 추가 실패'); + } + await refresh(); + }; + + const changeType = async (supplierItemId: string, supplyType: number) => { + await updateSupplyType(supplierItemId, { supply_type: supplyType }); + await refresh(); + }; + + const removeItem = async (supplierItemId: string) => { + await deleteSupplierItem(supplierItemId); + await refresh(); + }; + + return { items, isLoading: listQuery.isLoading, addItem, changeType, removeItem }; +} diff --git a/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx b/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx index b06cf7c..8e00e44 100644 --- a/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx +++ b/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx @@ -1,12 +1,18 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { X, PlusSquare, ArrowRight, Loader2, Gavel } from 'lucide-react'; import { useNavigate } from 'react-router'; import { useGetSupplierLastType } from '@/api/generated/quotation/quotation'; +import { useListItemSupplyTypes } from '@/api/generated/supplier-item/supplier-item'; +import { useListItems, useGetItem } from '@/api/generated/item/item'; +import { useListSuppliers } from '@/api/generated/supplier/supplier'; +import { useListCards } from '@/api/generated/card/card'; +import { mapCardData } from '@/features/cards/types'; import { Button } from '@/components/ui/button'; import { Typography, typographyVariants } from '@/components/ui/typography'; import { cn } from '@/lib/utils'; import { Input } from '@/components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Combobox, type ComboOption } from '@/components/ui/combobox'; import type { Product, Partner, QuotationSetting, NegotiationCard } from '../types'; import type { CreateQuotationInput } from '../hooks/useQuotations'; import { QuotationType } from '@/api/generated/model'; @@ -88,11 +94,71 @@ export function QuotationCreateModal({ }, [renegoSupplierId, prevSupplierType]); const navigate = useNavigate(); + + // ── 픽리스트 서버검색(상품/협력사/카드) — size 캡 없이 검색으로 도달. 미검색이면 부모가 넘긴 목록으로 기본 노출. + const [productQ, setProductQ] = useState(''); + const [productLabel, setProductLabel] = useState(''); + const [supplierQ, setSupplierQ] = useState(''); + const [cardQ, setCardQ] = useState(''); + const productSearch = useListItems({ search: productQ || undefined, size: 30 }); + const supplierSearch = useListSuppliers({ search: supplierQ || undefined, size: 30 }); + const cardSearch = useListCards({ search: cardQ || undefined, size: 30 }); + + // 선택 상품은 검색으로 목록이 좁혀져도 파생값(목표가 후보)이 안 깨지게 id로 단건 조회한다. + const selItem = useGetItem(productId, { query: { enabled: !!productId } }).data?.item ?? null; // 인터넷최저가·매입가·판매가는 상품 속성 — 모달에선 읽기전용으로만 보여주고, 수정은 상품 상세에서 한다. - const selectedProduct = products.find((p) => p.id === productId); - const internetLowest = selectedProduct?.internet_lowest_price ?? null; - const purchase = selectedProduct?.purchase_price ?? null; - const selling = selectedProduct?.selling_price ?? null; + const internetLowest = selItem?.internet_lowest_price ?? null; + const purchase = selItem?.purchase_price ?? null; + const selling = selItem?.selling_price ?? null; + + // 선택 상품의 협력사별 공급유형(제조/유통/총판/없음) — 협력사 리스트에 배지로 덧붙인다(리스트 자체는 재조회 안 함). + const supplyTypeQuery = useListItemSupplyTypes(productId, { query: { enabled: !!productId } }); + const supplyTypeBySupplier = useMemo(() => { + const m = new Map(); + (supplyTypeQuery.data?.suppliers ?? []).forEach((s) => m.set(s.supplier_id, s.supply_type)); + return m; + }, [supplyTypeQuery.data]); + + // 콤보박스 옵션 — 미검색이면 부모 목록, 검색 중이면 서버결과. + const productOptions: ComboOption[] = productQ + ? (productSearch.data?.items ?? []).map((it) => ({ id: it.item_id, label: `${it.name}${it.code ? ` [${it.code}]` : ''}` })) + : products.map((p) => ({ id: p.id ?? '', label: `${p.name}${p.code ? ` [${p.code}]` : ''}` })); + + const supplierRows = supplierQ + ? (supplierSearch.data?.suppliers ?? []).map((sp) => ({ id: sp.supplier_id, name: sp.name, email: sp.manager_email ?? '' })) + : partners.map((p) => ({ id: p.id ?? '', name: p.name, email: p.managerEmail ?? '' })); + const supplierOptions: ComboOption[] = supplierRows.map((s) => ({ + id: s.id, + label: s.name, + node: ( +
+
+ {s.name} + 이메일: {s.email} +
+ {productId && } +
+ ), + })); + + const cardRows = cardQ ? (cardSearch.data?.cards ?? []).map(mapCardData) : cards; + const cardOptions: ComboOption[] = cardRows + .filter((c) => !c.isWildcard || c.status === 'ACTIVE') + .map((card) => ({ + id: card.id, + label: card.title, + node: ( +
+
+ {card.code} + + {card.isWildcard ? '와일드' : '협상'} + +
+ {card.title} +
+ ), + })); // 상품에 산정 후보가 있는지(인터넷=공통, 매입·판매=재 한정). 없으면 MD가가 유일한 후보 → 필수가 된다. const mdNum = Number(mdPrice) || 0; const hasItemCandidate = internetLowest != null || (isReType && (purchase != null || selling != null)); @@ -247,25 +313,18 @@ export function QuotationCreateModal({
상품 - + { setProductId(opt.id); setProductLabel(opt.label); }} + placeholder="협상 대상 상품을 고르세요..." + searchPlaceholder="상품명·코드로 검색..." + emptyText="일치하는 상품이 없습니다" + />
{/* MD 제시가 — 입력 시 목표가로 사용. 상품에 다른 후보가 없으면 유일 후보라 필수. */} @@ -328,30 +387,19 @@ export function QuotationCreateModal({ {step === 2 && (
협력사 초청 ({oneToOne ? '단일선택' : '다중선택'}) -
- {partners.map((part) => { - const isChecked = selectedPartnerIds.includes(part.id ?? ''); - return ( - - ); - })} -
+ {/* 서버검색 다중선택 — 각 행에 선택 상품 취급유형 배지(미매핑=미취급). oneToOne이면 togglePartner가 단일로 강제. */} + togglePartner(opt.id)} + searchPlaceholder="협력사명·코드·담당자 검색..." + emptyText="협력사가 없습니다" + maxListHeight="max-h-56" + /> {/* 협력사 유형 — 항상 노출(처음부터 입력 가능). 재협상(1:1)이면 선택 협력사의 직전 견적 값으로 자동 디폴트. */}
@@ -454,35 +502,18 @@ export function QuotationCreateModal({ 1:1 협상에서 AI 협상봇이 발동할 카드입니다. -
- {cards.filter((c) => !c.isWildcard || c.status === 'ACTIVE').map((card) => { - const isChecked = selectedCardIds.includes(card.id); - return ( -
toggleCard(card.id)} - className={`p-2.5 rounded border cursor-pointer transition-all flex items-start gap-2 ${ - isChecked ? 'bg-primary/5 border-primary font-bold' : 'bg-background border-border hover:bg-muted/10' - }`} - > - -
-
- {card.code} - - {card.isWildcard ? '와일드' : '협상'} - -
- {card.title} -
-
- ); - })} -
+ toggleCard(opt.id)} + searchPlaceholder="카드명·번호·스크립트 검색..." + emptyText="협상카드가 없습니다" + maxListHeight="max-h-72" + />
)} @@ -529,6 +560,22 @@ export function QuotationCreateModal({ // ── 헬퍼 컴포넌트 (메인 아래) ────────────────────────────────────────────── +// 협력사 취급유형 배지 — type undefined = 이 상품 미취급, 그 외 SupplierType 라벨(제조/유통/총판/없음). +function SupplyTypeBadge({ type }: { type?: number }) { + if (type === undefined) { + return ( + + 미취급 + + ); + } + return ( + + {supplierTypeLabel(type)} + + ); +} + // 세그먼트 컨트롤 — 소수의 명명된 이산 선택(진행 방식·대상)에 라디오보다 명확. 값은 문자열. function Segmented({ options, diff --git a/postgres-init/00-init.sql b/postgres-init/00-init.sql index a487809..f4d6921 100644 --- a/postgres-init/00-init.sql +++ b/postgres-init/00-init.sql @@ -6,7 +6,7 @@ -- └── negosium_db -- ├── company : companies, users, user_tokens, notifications -- ├── supplier : supplier_users, supplier_user_tokens --- ├── partner : suppliers, items, item_internet_lowest_prices +-- ├── partner : suppliers, items, item_internet_lowest_prices, supplier_items -- ├── card : versions, nego_cards, wild_cards, version_nego_cards, version_wild_cards -- ├── quotation : quotation_settings, quotations -- ├── negotiation : sessions, chats, results @@ -185,6 +185,16 @@ CREATE TABLE IF NOT EXISTS partner.item_internet_lowest_prices ( deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부 ); +CREATE TABLE IF NOT EXISTS partner.supplier_items ( + supplier_item_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 매핑 식별자(PK) + supplier_id uuid NOT NULL, -- 협력사(partner.suppliers.supplier_id) + item_id uuid NOT NULL, -- 상품(partner.items.item_id) + supply_type SMALLINT NOT NULL DEFAULT 0, -- 공급 유형(SupplierType): 0=none(없음), 1=distribution(유통), 2=manufacture(제조), 3=sole_agency(총판) + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC) + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신) + deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부 +); + -- ============================================================ -- card : 협상 전략 (버전 / 협상카드 / 와일드카드 / 매핑) -- ============================================================ @@ -379,6 +389,8 @@ CREATE INDEX IF NOT EXISTS idx_suppliers_user_id ON partner.suppliers CREATE INDEX IF NOT EXISTS idx_items_company_id ON partner.items (company_id); CREATE INDEX IF NOT EXISTS idx_items_user_id ON partner.items (user_id); CREATE INDEX IF NOT EXISTS idx_iilp_item_id ON partner.item_internet_lowest_prices (item_id); +CREATE INDEX IF NOT EXISTS idx_supplier_items_supplier_id ON partner.supplier_items (supplier_id); +CREATE INDEX IF NOT EXISTS idx_supplier_items_item_id ON partner.supplier_items (item_id); CREATE INDEX IF NOT EXISTS idx_versions_user_id ON card.versions (user_id); CREATE INDEX IF NOT EXISTS idx_nego_cards_user_id ON card.nego_cards (user_id); CREATE INDEX IF NOT EXISTS idx_wild_cards_user_id ON card.wild_cards (user_id); @@ -401,6 +413,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_supplier_users_id ON supplier.supplier_ CREATE UNIQUE INDEX IF NOT EXISTS uq_companies_biz_number ON company.companies (business_number) WHERE deleted = FALSE AND business_number IS NOT NULL; CREATE UNIQUE INDEX IF NOT EXISTS uq_quotations_number ON quotation.quotations (number, round) WHERE deleted = FALSE; CREATE UNIQUE INDEX IF NOT EXISTS uq_chats_session_seq ON negotiation.chats (session_id, seq) WHERE deleted = FALSE; -- 세션 내 메시지 순번 유니크 (session 1:N, session_id 조회도 이 인덱스로 커버) +CREATE UNIQUE INDEX IF NOT EXISTS uq_supplier_items ON partner.supplier_items (supplier_id, item_id) WHERE deleted = FALSE; -- (협력사,상품) 매핑 중복 방지(소프트 삭제분은 재등록 허용) -- ============================================================