Merge branch 'main' into design/frontend

# Conflicts:
#	postgres-init/00-init.sql
#	postgres-init/01-schema_202607061714.sql
#	postgres-init/01-schema_202607070958.sql
#	postgres-init/04-alter_202607070958.sql
This commit is contained in:
민헌 2026-07-07 10:21:44 +09:00
commit d2710250e0
54 changed files with 2497 additions and 359 deletions

View File

View File

@ -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

View File

@ -89,9 +89,16 @@ class ChatEngine:
if price is None: if price is None:
return self._error(session, "가격을 숫자로 입력해 주세요.") return self._error(session, "가격을 숫자로 입력해 주세요.")
session.context["input_price"] = price session.context["input_price"] = price
# 협력사 첫 제시가 — 가격 수용률(첫 제시가 대비 양보율) 동적 계산의 기준값.
session.context.setdefault("first_offer_price", price)
session.context["round"] = session.context.get("round", 0) + 1 session.context["round"] = session.context.get("round", 0) + 1
nxt = self._default_next(node) nxt = self._default_next(node)
elif mode in _CHOICE_MODES: 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) nxt = self._choice_next(node, user_input, session)
else: else:
nxt = self._default_next(node) nxt = self._default_next(node)

View File

@ -2,9 +2,11 @@
인메모리 대신 learning.chat_sessions 에 진행 상태를 저장 → 서버 재시작/멀티워커 안전. 인메모리 대신 learning.chat_sessions 에 진행 상태를 저장 → 서버 재시작/멀티워커 안전.
company_id 스코프. ChatSession(dataclass) ↔ row 직렬화. company_id 스코프. ChatSession(dataclass) ↔ row 직렬화.
인터페이스(IChatSessionRepository) + 구현 형식 — backend crud 패턴 준용(테스트 더블 주입 가능).
""" """
import uuid import uuid
from abc import ABC, abstractmethod
from typing import Optional from typing import Optional
from sqlalchemy import select from sqlalchemy import select
@ -18,7 +20,19 @@ from common.utils.gtime import GTime
from negotiation.chat.service.chat_engine import ChatSession 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): def __init__(self, company_id: str):
self.company_id = company_id self.company_id = company_id

View File

@ -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

View File

@ -7,7 +7,7 @@
""" """
from dataclasses import dataclass, asdict from dataclasses import dataclass, asdict
from enum import Enum from enum import Enum, IntEnum
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
@ -19,6 +19,29 @@ class NegotiationOutcome(str, Enum):
FAILURE = "failure" 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 @dataclass
class NegotiationSnapshot: class NegotiationSnapshot:
# --- 이산 상태 산출 입력 --- # --- 이산 상태 산출 입력 ---

View File

@ -1,11 +1,9 @@
import os
import time import time
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.middleware.gzip import GZipMiddleware from fastapi.middleware.gzip import GZipMiddleware
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
from common.logger import LOG from common.logger import LOG
@ -22,7 +20,7 @@ API_SERVER_START_TIME = GTime.UTCStr()
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# startup: 공유 베이스(_base) 없으면 자동 시드 (운영 자동화, P5) # startup: 공유 베이스(_base) 없으면 자동 시드 (운영 자동화)
from bootstrap.lifespan import ensure_base_seeded from bootstrap.lifespan import ensure_base_seeded
await ensure_base_seeded() await ensure_base_seeded()
yield yield
@ -58,16 +56,6 @@ async def log_time(request: Request, call_next):
async def healthz(): async def healthz():
return API_SERVER_START_TIME 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.<domain>.<file> import 후 include. # 각 도메인 라우터 등록. 새 기능 추가 시 router.v1.<domain>.<file> import 후 include.
# (chat / card / learning / qtable 라우터는 P7 에서 Chat_server 14개 API 이식하며 추가) # (chat / card / learning / qtable 라우터는 P7 에서 Chat_server 14개 API 이식하며 추가)
app.include_router(router.v1.health.health.router) app.include_router(router.v1.health.health.router)

View File

@ -8,25 +8,22 @@ from common.models.gmodel import Req_WebPacketProtocol, Res_WebPacketProtocol
class Req_Chat(Req_WebPacketProtocol): class Req_Chat(Req_WebPacketProtocol):
"""대화 한 턴. session_id 없으면 새 협상 시작(아래 컨텍스트로). tenant 는 헤더로만.""" """대화 한 턴. session_id 없으면 새 협상 시작. tenant 는 헤더로만.
session_id: Optional[str] = Field(None, description="없으면 새 세션 생성") 협상 컨텍스트(rq_type/목표가/앵커링가/품목가/매출액/유통코드/파트너 유형)는 요청에 싣지
rq_type: str = Field("재협상", description="재협상 | 재견적") 않는다 — 세션 시작 시 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 모드)") user_input: Optional[str] = Field(None, description="버튼 선택 텍스트 또는 가격(price 모드)")
# ① desync 감지: backend 가 보는 직전 봇 step(내부 step 또는 표시 step). 없으면 검사 생략. # ① desync 감지: backend 가 보는 직전 봇 step(내부 step 또는 표시 step). 없으면 검사 생략.
# agent 는 자기 세션 step 을 정답으로 보고 진행하되, 불일치 시 경고 로깅하고 응답에 desynced 를 실어 # agent 는 자기 세션 step 을 정답으로 보고 진행하되, 불일치 시 경고 로깅하고 응답에 desynced 를 실어
# backend/front 가 agent 응답의 step/client_step 으로 리싱크하게 한다. # backend/front 가 agent 응답의 step/client_step 으로 리싱크하게 한다.
client_step: Optional[str] = Field(None, description="backend 가 본 직전 봇 step (desync 감지용)") 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): class Res_Chat(Res_WebPacketProtocol):
@ -52,6 +49,9 @@ class Res_Chat(Res_WebPacketProtocol):
updated_q: Optional[float] = None updated_q: Optional[float] = None
visit_count: Optional[int] = None visit_count: Optional[int] = None
reward_total: Optional[float] = None reward_total: Optional[float] = None
# 성공 확정 이후 턴(협상완료 요약·협상종료)에 내려주는 합의가. 와일드카드 1% 인하 수락 등
# 유저가 직접 입력하지 않은 가격으로 타결될 수 있어, backend 요약/입찰가는 이 값을 최우선 사용한다.
settled_price: Optional[int] = None
class Res_ChatSession(Res_WebPacketProtocol): class Res_ChatSession(Res_WebPacketProtocol):

View File

@ -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.chat_engine import ChatEngine, ChatSession, StepView
from negotiation.chat.service.indicator import compute_indicator from negotiation.chat.service.indicator import compute_indicator
from negotiation.chat.service.chat_session_repository import ChatSessionRepository 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.chat.service.script_repository import ScriptRepository
from negotiation.policies.base import EpisodeState, PolicyContext, Transition from negotiation.policies.base import EpisodeState, PolicyContext, Transition
from negotiation.policy.model_store import QTablePolicyStore 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.reward_calculator import RewardCalculator
from negotiation.qtable.domain.service.state_calculator import state_index from negotiation.qtable.domain.service.state_calculator import state_index
from negotiation.qtable.infra.repository.learning_repository import LearningRepository from negotiation.qtable.infra.repository.learning_repository import LearningRepository
from router.v1.chat.protocol import Req_Chat, Res_Chat, Res_ChatSession from router.v1.chat.protocol import Req_Chat, Res_Chat, Res_ChatSession
from tenancy.registry import TenantEngine 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: class ChatService:
async def chat(self, engine: TenantEngine, req: Req_Chat) -> Res_Chat: async def chat(self, engine: TenantEngine, req: Req_Chat) -> Res_Chat:
@ -33,7 +42,11 @@ class ChatService:
# 1) 세션 확보 / 시작 (DB 영속 — 재시작/멀티워커 안전, P8-A) # 1) 세션 확보 / 시작 (DB 영속 — 재시작/멀티워커 안전, P8-A)
session = await sess_repo.get(req.session_id) if req.session_id else None 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 과 다르면 경고. # ① step desync 감지: backend 가 본 직전 봇 step(client_step)이 agent 세션 step 과 다르면 경고.
# agent 가 자기 step 을 정답으로 보고 진행하고(응답의 step/client_step 으로 backend 가 따라옴), # agent 가 자기 step 을 정답으로 보고 진행하고(응답의 step/client_step 으로 backend 가 따라옴),
@ -54,14 +67,22 @@ class ChatService:
# 새 uuid 발급 없이 그대로 세션 키로 쓴다. 없으면(직접 호출/데모) 생성. # 새 uuid 발급 없이 그대로 세션 키로 쓴다. 없으면(직접 호출/데모) 생성.
session = ChatSession( session = ChatSession(
session_id=req.session_id or str(uuid.uuid4()), tenant_id=engine.tenant_id, company_id=engine.company_id, 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={ context={
"revenue_amount": req.revenue_amount, "distribution_code": req.distribution_code, # 매출액 = suppliers.total_revenue, 유통코드 = quotations.supplier_type 매핑 (loader).
"partner_count": req.partner_count, "acceptance_ratio": req.acceptance_ratio, # 미기재/미지정이면 기본값 폴백.
# 앵커링값은 갑(KT/iMK)이 직접 입력한 값을 사용 (UI 기본값 = target*(1-rate)). "revenue_amount": db_ctx.revenue_amount if db_ctx and db_ctx.revenue_amount > 0 else _DEFAULT_REVENUE_AMOUNT,
"anchor_price": req.anchor_price, "target_price": req.target_price, "round": 0, "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) view = chat_engine.start(session)
@ -78,6 +99,10 @@ class ChatService:
res.chat_end = view.chat_end res.chat_end = view.chat_end
res.outcome = view.outcome res.outcome = view.outcome
res.desynced = desynced 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) 학습 결합 (가격협상 카드선택 → 카드 스크립트·협상지표 / 종료 보상) # 3) 학습 결합 (가격협상 카드선택 → 카드 스크립트·협상지표 / 종료 보상)
if view.error is None and engine.action_space_size > 0: if view.error is None and engine.action_space_size > 0:
@ -112,11 +137,24 @@ class ChatService:
return res 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: def _snapshot(self, session: ChatSession, outcome: NegotiationOutcome) -> NegotiationSnapshot:
c = session.context c = session.context
return NegotiationSnapshot( return NegotiationSnapshot(
revenue_amount=c["revenue_amount"], distribution_code=c["distribution_code"], 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"], 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, target_price=c["target_price"], round_number=c.get("round", 0), outcome=outcome,
) )

View File

@ -1,142 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Negosium Agent — 협상 채팅 (테스트)</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, "Apple SD Gothic Neo", "Malgun Gothic", sans-serif; background: #eef1f5; color: #1c2530; }
header { background: #1f2d3d; color: #fff; padding: 12px 16px; }
header h1 { margin: 0; font-size: 16px; }
header p { margin: 4px 0 0; font-size: 12px; color: #9fb0c3; }
.wrap { max-width: 760px; margin: 0 auto; padding: 12px; }
.controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; background: #fff; padding: 10px; border-radius: 8px; margin-bottom: 10px; }
.controls label { font-size: 12px; color: #51607a; display: flex; flex-direction: column; gap: 2px; }
.controls input, .controls select { padding: 6px 8px; border: 1px solid #cdd6e3; border-radius: 6px; font-size: 13px; }
.controls input[type=number] { width: 110px; }
button { cursor: pointer; border: 0; border-radius: 6px; padding: 8px 12px; font-size: 13px; font-weight: 600; }
.btn-reset { background: #5b6b82; color: #fff; }
.chat { background: #fff; border-radius: 8px; height: 56vh; overflow-y: auto; padding: 14px; display: flex; flex-direction: column; gap: 10px; }
.row { display: flex; }
.row.agent { justify-content: flex-start; }
.row.user { justify-content: flex-end; }
.row.sys { justify-content: center; }
.bubble { max-width: 80%; padding: 9px 12px; border-radius: 12px; font-size: 13px; line-height: 1.55; white-space: pre-wrap; }
.agent .bubble { background: #eef1f6; color: #28323f; border-bottom-left-radius: 3px; }
.user .bubble { background: #2f6fed; color: #fff; border-bottom-right-radius: 3px; }
.sys .bubble { background: #fff5d6; color: #6b5500; font-size: 12px; border: 1px solid #f0e2a8; }
.card { margin-top: 7px; font-size: 12px; background: #0f1b2d; color: #9fe0c0; border-radius: 6px; padding: 6px 8px; }
.controls-area { margin-top: 10px; background: #fff; padding: 10px; border-radius: 8px; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; min-height: 52px; }
.opt { background: #2f6fed; color: #fff; }
.opt.alt { background: #5b6b82; }
.price-in { padding: 8px 10px; border: 1px solid #cdd6e3; border-radius: 6px; font-size: 14px; width: 160px; }
.btn-send { background: #1f9d57; color: #fff; }
.note { font-size: 11px; color: #6b7890; margin-top: 8px; line-height: 1.5; }
.pill { display:inline-block; background:#0f1b2d; color:#9fe0c0; border-radius:4px; padding:1px 6px; font-size:11px; margin-left:6px;}
</style>
</head>
<body>
<header>
<h1>Negosium Agent — 협상 채팅 <span class="pill" id="srv">/v1/chat</span></h1>
<p>KT 구매자 관점: 협력사(판매자)가 제시가를 입력 → <b>앵커가 이하면 우선협상(타결)</b>, 초과면 카드로 인하 협상(카드 소진까지). 낮게 매입할수록 KT 이득.</p>
</header>
<div class="wrap">
<div class="controls">
<label>테넌트
<select id="tenant" onchange="reset()">
<option value="ktcommerce">ktcommerce (데모상사 A)</option>
<option value="imarketkorea">imarketkorea (데모상사 B)</option>
</select>
</label>
<label>유형
<select id="rq" onchange="reset()"><option value="재협상">재협상</option><option value="재견적">재견적</option></select>
</label>
<label>목표가(KT 매입)<input type="number" id="target" value="10000" oninput="suggestAnchor()" /></label>
<label>앵커가(KT 앵커링, ≤목표)<input type="number" id="anchor" value="9900" /></label>
<button class="btn-reset" onclick="reset()">새 협상 시작</button>
</div>
<div class="chat" id="chat"></div>
<div class="controls-area" id="controls"></div>
<div class="note" id="note"></div>
</div>
<script>
let sid = null, ended = false;
const $ = id => document.getElementById(id);
// 갑(KT)이 앵커가를 직접 입력. 목표가 변경 시 기본 제안값(target*0.99)을 채워주되 편집 가능.
function suggestAnchor() {
const t = Number($('target').value);
if (t > 0) $('anchor').value = Math.round(t * 0.99);
}
function bubble(side, html) {
const row = document.createElement('div'); row.className = 'row ' + side;
const b = document.createElement('div'); b.className = 'bubble'; b.innerHTML = html;
row.appendChild(b); $('chat').appendChild(row); $('chat').scrollTop = 1e9;
}
const sys = t => bubble('sys', t);
async function reset() {
sid = null; ended = false; $('chat').innerHTML = ''; $('controls').innerHTML = '';
sys(`새 협상 · ${$('tenant').value} · ${$('rq').value} · 앵커 ${$('anchor').value} → 목표 ${$('target').value}`);
await send(null);
}
async function send(userInput) {
if (ended) return;
const body = { session_id: sid, rq_type: $('rq').value, user_input: userInput,
anchor_price: Number($('anchor').value), target_price: Number($('target').value) };
let res;
try {
const r = await fetch('/v1/chat', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': $('tenant').value },
body: JSON.stringify(body) });
res = await r.json();
} catch (e) { sys('서버 호출 실패: ' + e); return; }
sid = res.session_id;
if (res.result && !res.result.success) { sys('처리 불가: ' + res.result.desc + ' — ' + (res.msg||'')); return; }
let html = (res.script || '').replace(/\n/g, '<br>');
if (res.card_id) {
html += `<div class="card">🃏 제시 카드 <b>${res.card_id}</b> · 학습 Q→${(res.updated_q||0).toFixed(3)} · visit ${res.visit_count||0} · reward ${(res.reward_total!=null?res.reward_total.toFixed(3):'-')}</div>`;
}
if (res.outcome) {
html += `<div class="card">📌 협상 ${res.outcome==='success'?'타결':'결렬'} · 종료보상 ${(res.reward_total!=null?res.reward_total.toFixed(3):'-')}</div>`;
}
bubble('agent', html);
renderControls(res);
if (res.chat_end) { ended = true; $('controls').innerHTML = '<span style="color:#6b7890;font-size:12px">대화가 종료되었습니다. "새 협상 시작"을 누르세요.</span>'; }
}
function renderControls(res) {
const c = $('controls'); c.innerHTML = '';
if (res.chat_end) return;
const mode = res.input_mode;
if (mode === 'price') {
const inp = document.createElement('input'); inp.type = 'number'; inp.className = 'price-in'; inp.placeholder = '제시 가격 입력'; inp.value = '';
const btn = document.createElement('button'); btn.className = 'btn-send'; btn.textContent = '제시';
const go = () => { const v = inp.value.trim(); if (!v) return; userSay(v + '원 제시'); send(v); };
btn.onclick = go; inp.onkeydown = e => { if (e.key === 'Enter') go(); };
c.appendChild(inp); c.appendChild(btn); inp.focus();
} else if (Array.isArray(res.input_options) && res.input_options.length) {
res.input_options.forEach((opt, i) => {
const btn = document.createElement('button'); btn.className = 'opt' + (i > 0 ? ' alt' : '');
btn.textContent = opt; btn.onclick = () => { userSay(opt); send(opt); };
c.appendChild(btn);
});
} else {
const btn = document.createElement('button'); btn.className = 'opt'; btn.textContent = '계속';
btn.onclick = () => send(null); c.appendChild(btn);
}
}
function userSay(t){ bubble('user', t); }
reset();
</script>
</body>
</html>

View File

@ -33,8 +33,8 @@ async def test_4_1_honors_backend_session_id(db_engine):
svc = ChatService() svc = ChatService()
# 첫 턴: backend 의 session_id 를 그대로 키로 써야 함 (새 uuid 발급 X) # 첫 턴: backend 의 session_id 를 그대로 키로 써야 함 (새 uuid 발급 X)
r = await svc.chat(eng, Req_Chat(session_id=BACKEND_SESSION_ID, rq_type="재협상", # 컨텍스트는 DB 조회(행 없음 → 기본값 폴백: target=10000, anchor=9900)
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 assert r.session_id == BACKEND_SESSION_ID
assert r.step == "서비스안내" assert r.step == "서비스안내"
@ -62,8 +62,7 @@ async def test_4_4_company_id_chat_end_to_end(db_engine):
reset_sessions() reset_sessions()
eng = await _reg().get_engine(COMPANY_ID) # 자동 온보딩 테넌트 eng = await _reg().get_engine(COMPANY_ID) # 자동 온보딩 테넌트
svc = ChatService() svc = ChatService()
r = await svc.chat(eng, Req_Chat(session_id=BACKEND_SESSION_ID, rq_type="재협상", r = await svc.chat(eng, Req_Chat(session_id=BACKEND_SESSION_ID))
target_price=10000, anchor_price=9900))
assert r.session_id == BACKEND_SESSION_ID and r.step == "서비스안내" assert r.session_id == BACKEND_SESSION_ID and r.step == "서비스안내"
# 카드선택 턴까지 진행 → company_id 스코프로 학습 기록 # 카드선택 턴까지 진행 → company_id 스코프로 학습 기록
for ui in ["확인", "예", "확인", "11000", "예"]: for ui in ["확인", "예", "확인", "11000", "예"]:

View File

@ -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"

View File

@ -31,7 +31,7 @@ async def _run(svc, eng, turns):
"""turns: user_input 리스트(첫 None=시작). 반환: 응답 리스트.""" """turns: user_input 리스트(첫 None=시작). 반환: 응답 리스트."""
sid, out = None, [] sid, out = None, []
for ui in turns: 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 sid = r.session_id
out.append(r) out.append(r)
if r.chat_end: if r.chat_end:
@ -76,6 +76,48 @@ async def test_full_conversation_reaches_completion(db_engine):
assert cnt >= 1 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 @pytest.mark.asyncio
async def test_priority_completes_without_wildcard(db_engine): async def test_priority_completes_without_wildcard(db_engine):
reset_sessions() reset_sessions()
@ -95,7 +137,7 @@ async def test_tenant_brand_isolation(db_engine):
reset_sessions() reset_sessions()
svc = ChatService() svc = ChatService()
reg = _reg() 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 assert "데모상사 B" in ik.script

View File

@ -27,19 +27,19 @@ async def test_session_persists_and_resumes_across_instances(db_engine):
reg = _eng() reg = _eng()
eng = await reg.get_engine("ktcommerce") eng = await reg.get_engine("ktcommerce")
# 인스턴스 1: 협상 시작 + 몇 턴 진행 # 인스턴스 1: 협상 시작 + 몇 턴 진행 (컨텍스트는 DB 조회 — 행이 없으므로 기본값 폴백)
svc1 = ChatService() 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 sid = r.session_id
for ui in ["확인", "예", "확인", "11000"]: for ui in ["확인", "예", "확인", "11000"]:
r = await svc1.chat(eng, Req_Chat(session_id=sid, user_input=ui)) r = await svc1.chat(eng, Req_Chat(session_id=sid, user_input=ui))
step_before = r.step step_before = r.step
assert step_before == "가격협상_확인" # 11000 제시 후 확인 단계 assert step_before == "가격협상_확인" # 11000 제시 후 확인 단계
# DB 에 저장됐는지 확인 # DB 에 저장됐는지 확인 (기본 컨텍스트: anchor=9900)
saved = await ChatSessionRepository(eng.company_id).get(sid) saved = await ChatSessionRepository(eng.company_id).get(sid)
assert saved is not None and saved.step == step_before 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 로 이어가기 # 인스턴스 2 (재시작/다른 워커 모사): 같은 session_id 로 이어가기
svc2 = ChatService() svc2 = ChatService()
@ -53,7 +53,7 @@ async def test_session_company_scoped(db_engine):
reg = _eng() reg = _eng()
eng = await reg.get_engine("ktcommerce") eng = await reg.get_engine("ktcommerce")
svc = ChatService() svc = ChatService()
r = await svc.chat(eng, Req_Chat(rq_type="재협상")) r = await svc.chat(eng, Req_Chat())
sid = r.session_id sid = r.session_id
# 자사(ktcommerce)로는 조회됨 # 자사(ktcommerce)로는 조회됨

View File

@ -32,26 +32,24 @@ class AgentTurn:
indicator_value: Optional[float] = None indicator_value: Optional[float] = None
# agent 가 직접 내려주는 표현 폼(summaryRSP/CM·rejectRSP/CM·indicator). 없으면 backend 가 step+qt_type 으로 폴백. # agent 가 직접 내려주는 표현 폼(summaryRSP/CM·rejectRSP/CM·indicator). 없으면 backend 가 step+qt_type 으로 폴백.
bot_chat_type: Optional[str] = None bot_chat_type: Optional[str] = None
# 성공 확정 이후 턴에 agent 가 내려주는 합의가. 와일드카드 1% 인하 수락처럼 유저가 직접
# 입력하지 않은 가격으로 타결될 수 있어, 요약 표시가·입찰가 확정 시 이 값을 최우선 사용한다.
settled_price: Optional[int] = None
ok: bool = True # agent 호출 성공 여부 (False 면 CHAT_AGENT_UNAVAILABLE) ok: bool = True # agent 호출 성공 여부 (False 면 CHAT_AGENT_UNAVAILABLE)
timed_out: bool = False # 타임아웃 여부. True 면 agent 가 이미 진행했을 수 있어 desync 위험 → 별도 처리. timed_out: bool = False # 타임아웃 여부. True 면 agent 가 이미 진행했을 수 있어 desync 위험 → 별도 처리.
@dataclass @dataclass
class AgentChatContext: 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 tenant_id: str # X-Tenant-ID = 견적(갑) 회사 company_id
rq_type: str = "재협상" # 재협상 | 재견적 rq_type: str = "재협상" # 재협상(1:1) | 재견적(1:N) — backend 로컬 용도
target_price: int = 0 # 갑 목표 매입가(원) target_price: int = 0 # 갑 목표 매입가(원) — backend 로컬 용도
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 소스 없음(기본값)
# 핸드오프 #1: backend 가 보는 현재 step(직전 봇 step). agent 가 자기 세션 step 과 대조해 desync 감지에 쓸 수 있다. # 핸드오프 #1: backend 가 보는 현재 step(직전 봇 step). agent 가 자기 세션 step 과 대조해 desync 감지에 쓸 수 있다.
client_step: Optional[str] = None client_step: Optional[str] = None
extra: dict = field(default_factory=dict) extra: dict = field(default_factory=dict)
@ -72,16 +70,9 @@ class HttpAgentClient(IAgentClient):
body = { body = {
"session_id": session_id, # 핸드오프 #1: agent 가 이 값을 세션 키로 그대로 사용해야 함 "session_id": session_id, # 핸드오프 #1: agent 가 이 값을 세션 키로 그대로 사용해야 함
"rq_type": ctx.rq_type, # 협상 컨텍스트(rq_type/목표가/앵커/품목가/매출액/유통코드/파트너 유형/수용률)는 보내지
# 않는다 — agent 가 session_id 로 DB 에서 직접 조회·계산한다(NegotiationContextLoader).
"user_input": user_input, "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 감지에 사용(미구현 시 무시됨). # 핸드오프 #1: backend 가 보는 직전 step. agent 가 desync 감지에 사용(미구현 시 무시됨).
"client_step": ctx.client_step, "client_step": ctx.client_step,
} }
@ -111,6 +102,7 @@ class HttpAgentClient(IAgentClient):
card_id=data.get("card_id"), card_id=data.get("card_id"),
indicator_value=data.get("indicator_value"), indicator_value=data.get("indicator_value"),
bot_chat_type=data.get("bot_chat_type"), bot_chat_type=data.get("bot_chat_type"),
settled_price=data.get("settled_price"),
ok=True, ok=True,
) )

View File

@ -361,7 +361,9 @@ class ChatService:
summary = None summary = None
if bot_chat_type in ("summaryRSP", "summaryCM"): if bot_chat_type in ("summaryRSP", "summaryCM"):
final_price = price if price is not None else (sess.bid_price or sess.target_price) 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+거부사유·제시가). 한 트랜잭션. # 봇 메시지 + 종료 시 확정(성공=DONE+입찰가 / 실패=REJECTED+거부사유·제시가). 한 트랜잭션.
bot_msg = self._build_bot_chat(sess, seq=max_seq + 2, turn=turn, bot_chat_type=bot_chat_type, summary=summary) 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.chat_end:
if turn.outcome == "success": if turn.outcome == "success":
new_status = SessionStatus.DONE.value new_status = SessionStatus.DONE.value
# 입찰가 = 이번 턴 가격(보통 None) → 마지막 제시가 → 목표가 순으로 확정. # 입찰가 = agent 합의가(settled_price, 와일드카드 수락 등) → 이번 턴 가격(보통 None)
bid = price if price is not None else (last_price if last_price else sess.target_price) # → 마지막 제시가 → 목표가 순으로 확정.
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} " LOG.w(f"[chat] 합의가 폴백→목표가 session_id={sess.session_id} bid={bid} "
f"— 협상 중 가격 제시가 기록되지 않음(프론트 user_input_type='price' 누락 의심)") f"— 협상 중 가격 제시가 기록되지 않음(프론트 user_input_type='price' 누락 의심)")
funcs.append(lambda s: self.chat_crud.finalize_session(s, sess.session_id, new_status, bid_price=bid)) 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) tenant_id = str(item.company_id)
else: else:
LOG.w(f"[chat] tenant_id 해석 실패(item.company_id 없음) session_id={sess.session_id} — agent 400 위험") 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) target_price = int(sess.target_price or 0)
# 앵커가: 세션 생성 시 박제된 값(anchoring_price)을 그대로 사용 — 협상 중 불변. # 협상 컨텍스트(앵커가/품목가/매출액/유통코드/파트너 유형/수용률)는 더 이상 계산·전송하지 않는다 —
anchor = await self._resolve_anchor_price(sess, target_price) # agent 가 session_id 로 DB(negotiation.sessions·partner.items/suppliers·quotations)에서 직접 조회한다.
# 공급사 수: 같은 견적에 속한 세션 수(재협상=1, 재견적=N). agent partner 차원(single/multiple/none) 입력. # (앵커가 박제·무할인 폴백 정책 — schedules/anchoring/docs/개발용.md §9.2 — 은 agent loader 가 승계.)
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
return AgentChatContext( return AgentChatContext(
tenant_id=tenant_id, rq_type=rq_type, tenant_id=tenant_id, rq_type=rq_type,
target_price=target_price, anchor_price=anchor, partner_count=partner_count, target_price=target_price, client_step=client_step,
item_price=item_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]: async def _last_user_price(self, sess) -> Optional[int]:
"""세션에서 가장 최근 유저 제시가(negotiation.chats.target_price>0). 없으면 None.""" """세션에서 가장 최근 유저 제시가(negotiation.chats.target_price>0). 없으면 None."""
def _q(s): def _q(s):

View File

@ -32,15 +32,17 @@ class _AnchorAgent(IAgentClient):
"""결정론적 더블: 서비스안내(오프닝) → 가격 입력 요청 → 합의 종료. """결정론적 더블: 서비스안내(오프닝) → 가격 입력 요청 → 합의 종료.
앵커보다 높은 가격이면 같은 step 을 반복(마지막 제시가 덮어쓰기 검증용). 앵커보다 높은 가격이면 같은 step 을 반복(마지막 제시가 덮어쓰기 검증용).
매 턴 수신한 ctx.anchor_price 를 기록해 backend 의 앵커 해석을 관찰한다. 앵커 해석(박제값 소비/무할인 폴백)은 agent 가 DB 에서 직접 수행하도록 이관됐다
(script 에 앵커가 보이는 건 테스트 관찰 편의일 뿐 — 실제 agent 는 비노출.) (NegotiationContextLoader — agent tests/test_context_loader.py 가 검증). 여기 더블은
자체 앵커 상수로 수락 여부만 판정하고, backend 가 보내는 ctx.target_price 를 관찰한다.
""" """
def __init__(self): def __init__(self, anchor: int = ANCHOR):
self.seen_anchors: list[int] = [] self.anchor = anchor
self.seen_targets: list[int] = []
async def chat(self, session_id, user_input, ctx) -> AgentTurn: 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" sid = session_id or "fake-session"
if user_input is None: # 오프닝(턴0) if user_input is None: # 오프닝(턴0)
return AgentTurn(session_id=sid, step="서비스안내", client_step="서비스안내", return AgentTurn(session_id=sid, step="서비스안내", client_step="서비스안내",
@ -48,10 +50,10 @@ class _AnchorAgent(IAgentClient):
input_options=["네, 시작할게요"]) input_options=["네, 시작할게요"])
if ctx.client_step == "서비스안내": if ctx.client_step == "서비스안내":
return AgentTurn(session_id=sid, step="기존가격제시", client_step="기존가격제시", return AgentTurn(session_id=sid, step="기존가격제시", client_step="기존가격제시",
script=f"저희가 제안드리는 첫 목표 가격은 {ctx.anchor_price}원입니다. " script=f"저희가 제안드리는 첫 목표 가격은 {self.anchor}원입니다. "
f"제안하실 가격을 입력해 주세요.", input_mode="price") f"제안하실 가격을 입력해 주세요.", input_mode="price")
price = _parse_price(user_input) 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="협상종료", return AgentTurn(session_id=sid, step="협상종료", client_step="협상종료",
script=f"{price:,}원으로 합의되었습니다.", chat_end=True, outcome="success") script=f"{price:,}원으로 합의되었습니다.", chat_end=True, outcome="success")
return AgentTurn(session_id=sid, step="기존가격제시", client_step="기존가격제시", 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"] == "기존가격제시" assert r.status_code == 200 and r.json()["message"]["step"] == "기존가격제시"
row = await _anchor_columns(db_engine, sid) row = await _anchor_columns(db_engine, sid)
assert row.last_offer_price is None 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 반복 r = await _send(client, token, sid, "99,500", "price") # 앵커 초과 → 같은 step 반복
assert r.json()["message"]["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) # 박제 불변 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): async def test_null_snapshot_falls_back_to_target(client, db_engine, anchor_seed, _fake_agent):
sid = str(anchor_seed["sids"]["N"]) sid = str(anchor_seed["sids"]["N"])
token = await _login_token(client) 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) await _messages(client, token, sid)
r = await _send(client, token, sid, "네, 시작할게요") r = await _send(client, token, sid, "네, 시작할게요")
assert r.json()["message"]["step"] == "기존가격제시" 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 assert r.json()["session_status"] == 3
row = await _anchor_columns(db_engine, sid) 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_price is None # backend 는 박제하지 않음(앵커 없음 → 집계 제외)
assert row.anchoring_value is None assert row.anchoring_value is None
assert row.last_offer_price == 97_000 # 가격 흔적 기록은 정상 동작 assert row.last_offer_price == 97_000 # 가격 흔적 기록은 정상 동작

View File

@ -124,7 +124,19 @@ class suppliers(MainTableMixin, MAIN_BASE):
manager_name = Column(String(50), nullable=True) manager_name = Column(String(50), nullable=True)
manager_email = Column(String(255), nullable=True) manager_email = Column(String(255), nullable=True)
manager_contact_number = Column(String(20), nullable=True) # ERD 오타(manger) 교정 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): class nego_cards(MainTableMixin, MAIN_BASE):

View File

@ -57,6 +57,7 @@ class ErrorType(Enum):
# 협력사 관련 에러 # 협력사 관련 에러
SUPPLIER_NOT_FOUND = 1400 SUPPLIER_NOT_FOUND = 1400
SUPPLIER_CODE_DUPLICATE = auto() SUPPLIER_CODE_DUPLICATE = auto()
SUPPLIER_ITEM_NOT_FOUND = auto() # 협력사-상품 매핑 미존재
# 견적 관련 에러 # 견적 관련 에러
QUOTATION_NOT_FOUND = 1500 QUOTATION_NOT_FOUND = 1500

View File

@ -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

View File

@ -14,6 +14,7 @@ import router.v1.auth.account
import router.v1.company.user import router.v1.company.user
import router.v1.item.item import router.v1.item.item
import router.v1.supplier.supplier import router.v1.supplier.supplier
import router.v1.supplier_item.supplier_item
import router.v1.card.card import router.v1.card.card
import router.v1.quotation.quotation import router.v1.quotation.quotation
import router.v1.quotation_setting.quotation_setting 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.company.user.router)
app.include_router(router.v1.item.item.router) app.include_router(router.v1.item.item.router)
app.include_router(router.v1.supplier.supplier.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.card.card.router)
app.include_router(router.v1.quotation.quotation.router) app.include_router(router.v1.quotation.quotation.router)
app.include_router(router.v1.quotation_setting.quotation_setting.router) app.include_router(router.v1.quotation_setting.quotation_setting.router)

View File

@ -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] = [] # 회사 상품 목록에 이름이 없어 매핑 못한 입력명들

View File

@ -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)))

View File

@ -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

View File

@ -69,6 +69,7 @@ export * from './itemDataSellingPrice';
export * from './itemDataSpec'; export * from './itemDataSpec';
export * from './itemDataUpdatedAt'; export * from './itemDataUpdatedAt';
export * from './itemDataVatYn'; export * from './itemDataVatYn';
export * from './itemSupplyType';
export * from './listCardsParams'; export * from './listCardsParams';
export * from './listItemsParams'; export * from './listItemsParams';
export * from './listNotificationsParams'; export * from './listNotificationsParams';
@ -119,6 +120,7 @@ export * from './quotationSettingDataUpdatedAt';
export * from './quotationSettingDataUserId'; export * from './quotationSettingDataUserId';
export * from './quotationStatus'; export * from './quotationStatus';
export * from './quotationType'; export * from './quotationType';
export * from './reqBulkMapByNames';
export * from './reqCheckCodes'; export * from './reqCheckCodes';
export * from './reqCreateCard'; export * from './reqCreateCard';
export * from './reqCreateCardCondition'; export * from './reqCreateCardCondition';
@ -160,6 +162,7 @@ export * from './reqCreateQuotationSupplierType';
export * from './reqCreateQuotationVersionId'; export * from './reqCreateQuotationVersionId';
export * from './reqCreateSupplier'; export * from './reqCreateSupplier';
export * from './reqCreateSupplierCode'; export * from './reqCreateSupplierCode';
export * from './reqCreateSupplierItem';
export * from './reqCreateSupplierManagerContactNumber'; export * from './reqCreateSupplierManagerContactNumber';
export * from './reqCreateSupplierManagerEmail'; export * from './reqCreateSupplierManagerEmail';
export * from './reqCreateSupplierManagerName'; export * from './reqCreateSupplierManagerName';
@ -217,6 +220,9 @@ export * from './reqUpdateSupplierManagerEmail';
export * from './reqUpdateSupplierManagerName'; export * from './reqUpdateSupplierManagerName';
export * from './reqUpdateSupplierName'; export * from './reqUpdateSupplierName';
export * from './reqUpdateSupplierTotalRevenue'; export * from './reqUpdateSupplierTotalRevenue';
export * from './reqUpdateSupplyType';
export * from './resBulkMapByNames';
export * from './resBulkMapByNamesMsg';
export * from './resCard'; export * from './resCard';
export * from './resCardCard'; export * from './resCardCard';
export * from './resCardList'; export * from './resCardList';
@ -258,6 +264,8 @@ export * from './resItemItem';
export * from './resItemList'; export * from './resItemList';
export * from './resItemListMsg'; export * from './resItemListMsg';
export * from './resItemMsg'; export * from './resItemMsg';
export * from './resItemSupplyTypeList';
export * from './resItemSupplyTypeListMsg';
export * from './resLastSupplierType'; export * from './resLastSupplierType';
export * from './resLastSupplierTypeMsg'; export * from './resLastSupplierTypeMsg';
export * from './resLastSupplierTypeQtNumber'; export * from './resLastSupplierTypeQtNumber';
@ -312,6 +320,11 @@ export * from './resSessionChat';
export * from './resSessionChatMsg'; export * from './resSessionChatMsg';
export * from './resSessionChatSessionId'; export * from './resSessionChatSessionId';
export * from './resSupplier'; export * from './resSupplier';
export * from './resSupplierItem';
export * from './resSupplierItemList';
export * from './resSupplierItemListMsg';
export * from './resSupplierItemMsg';
export * from './resSupplierItemSupplierItem';
export * from './resSupplierList'; export * from './resSupplierList';
export * from './resSupplierListMsg'; export * from './resSupplierListMsg';
export * from './resSupplierMsg'; export * from './resSupplierMsg';
@ -324,6 +337,8 @@ export * from './resTargetBreakdownMdPrice';
export * from './resTargetBreakdownMsg'; export * from './resTargetBreakdownMsg';
export * from './resTargetBreakdownPurchase'; export * from './resTargetBreakdownPurchase';
export * from './resTargetBreakdownSelling'; export * from './resTargetBreakdownSelling';
export * from './resWebPacketProtocol';
export * from './resWebPacketProtocolMsg';
export * from './sessionData'; export * from './sessionData';
export * from './sessionDataAnchoringPrice'; export * from './sessionDataAnchoringPrice';
export * from './sessionDataBidAt'; export * from './sessionDataBidAt';
@ -341,6 +356,10 @@ export * from './supplierDataManagerEmail';
export * from './supplierDataManagerName'; export * from './supplierDataManagerName';
export * from './supplierDataTotalRevenue'; export * from './supplierDataTotalRevenue';
export * from './supplierDataUpdatedAt'; export * from './supplierDataUpdatedAt';
export * from './supplierItemData';
export * from './supplierItemDataCreatedAt';
export * from './supplierItemDataItemCode';
export * from './supplierItemDataUpdatedAt';
export * from './supplierType'; export * from './supplierType';
export * from './targetCandidate'; export * from './targetCandidate';
export * from './userRole'; export * from './userRole';

View File

@ -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;
}

View File

@ -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[];
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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[];
}

View File

@ -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;

View File

@ -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[];
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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[];
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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<T extends (...args: never) => unknown> = Parameters<T>[1];
/**
* @summary 협력사 취급상품 목록
*/
export const listSupplierItems = (
supplierId: string,
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
) => {
return customFetch<ResSupplierItemList>(
{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 = <TData = Awaited<ReturnType<typeof listSupplierItems>>, TError = void | HTTPValidationError>(supplierId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listSupplierItems>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListSupplierItemsQueryKey(supplierId);
const queryFn: QueryFunction<Awaited<ReturnType<typeof listSupplierItems>>> = ({ signal }) => listSupplierItems(supplierId, requestOptions, signal);
return { queryKey, queryFn, enabled: !!(supplierId), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listSupplierItems>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type ListSupplierItemsQueryResult = NonNullable<Awaited<ReturnType<typeof listSupplierItems>>>
export type ListSupplierItemsQueryError = void | HTTPValidationError
export function useListSupplierItems<TData = Awaited<ReturnType<typeof listSupplierItems>>, TError = void | HTTPValidationError>(
supplierId: string, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof listSupplierItems>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof listSupplierItems>>,
TError,
Awaited<ReturnType<typeof listSupplierItems>>
> , 'initialData'
>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useListSupplierItems<TData = Awaited<ReturnType<typeof listSupplierItems>>, TError = void | HTTPValidationError>(
supplierId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listSupplierItems>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof listSupplierItems>>,
TError,
Awaited<ReturnType<typeof listSupplierItems>>
> , 'initialData'
>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useListSupplierItems<TData = Awaited<ReturnType<typeof listSupplierItems>>, TError = void | HTTPValidationError>(
supplierId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listSupplierItems>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary 협력사 취급상품 목록
*/
export function useListSupplierItems<TData = Awaited<ReturnType<typeof listSupplierItems>>, TError = void | HTTPValidationError>(
supplierId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listSupplierItems>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getListSupplierItemsQueryOptions(supplierId,options)
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey ;
return query;
}
/**
* @summary 상품 취급 협력사 공급유형 목록
*/
export const listItemSupplyTypes = (
itemId: string,
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
) => {
return customFetch<ResItemSupplyTypeList>(
{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 = <TData = Awaited<ReturnType<typeof listItemSupplyTypes>>, TError = void | HTTPValidationError>(itemId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listItemSupplyTypes>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListItemSupplyTypesQueryKey(itemId);
const queryFn: QueryFunction<Awaited<ReturnType<typeof listItemSupplyTypes>>> = ({ signal }) => listItemSupplyTypes(itemId, requestOptions, signal);
return { queryKey, queryFn, enabled: !!(itemId), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listItemSupplyTypes>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type ListItemSupplyTypesQueryResult = NonNullable<Awaited<ReturnType<typeof listItemSupplyTypes>>>
export type ListItemSupplyTypesQueryError = void | HTTPValidationError
export function useListItemSupplyTypes<TData = Awaited<ReturnType<typeof listItemSupplyTypes>>, TError = void | HTTPValidationError>(
itemId: string, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof listItemSupplyTypes>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof listItemSupplyTypes>>,
TError,
Awaited<ReturnType<typeof listItemSupplyTypes>>
> , 'initialData'
>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useListItemSupplyTypes<TData = Awaited<ReturnType<typeof listItemSupplyTypes>>, TError = void | HTTPValidationError>(
itemId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listItemSupplyTypes>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof listItemSupplyTypes>>,
TError,
Awaited<ReturnType<typeof listItemSupplyTypes>>
> , 'initialData'
>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useListItemSupplyTypes<TData = Awaited<ReturnType<typeof listItemSupplyTypes>>, TError = void | HTTPValidationError>(
itemId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listItemSupplyTypes>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary 상품 취급 협력사 공급유형 목록
*/
export function useListItemSupplyTypes<TData = Awaited<ReturnType<typeof listItemSupplyTypes>>, TError = void | HTTPValidationError>(
itemId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listItemSupplyTypes>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getListItemSupplyTypesQueryOptions(itemId,options)
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey ;
return query;
}
/**
* @summary 취급상품 매핑 추가
*/
export const createSupplierItem = (
reqCreateSupplierItem: ReqCreateSupplierItem,
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
) => {
return customFetch<ResSupplierItem>(
{url: `/v1/supplier-item/create`, method: 'POST',
headers: {'Content-Type': 'application/json', },
data: reqCreateSupplierItem, signal
},
options);
}
export const getCreateSupplierItemMutationOptions = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createSupplierItem>>, TError,{data: ReqCreateSupplierItem}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof createSupplierItem>>, 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<Awaited<ReturnType<typeof createSupplierItem>>, {data: ReqCreateSupplierItem}> = (props) => {
const {data} = props ?? {};
return createSupplierItem(data,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type CreateSupplierItemMutationResult = NonNullable<Awaited<ReturnType<typeof createSupplierItem>>>
export type CreateSupplierItemMutationBody = ReqCreateSupplierItem
export type CreateSupplierItemMutationError = void | HTTPValidationError
/**
* @summary 취급상품 매핑 추가
*/
export const useCreateSupplierItem = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createSupplierItem>>, TError,{data: ReqCreateSupplierItem}, TContext>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof createSupplierItem>>,
TError,
{data: ReqCreateSupplierItem},
TContext
> => {
const mutationOptions = getCreateSupplierItemMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}
/**
* @summary 취급상품 이름 일괄 매핑(엑셀 업로드)
*/
export const bulkMapSupplierItems = (
supplierId: string,
reqBulkMapByNames: ReqBulkMapByNames,
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
) => {
return customFetch<ResBulkMapByNames>(
{url: `/v1/supplier-item/by-supplier/${supplierId}/bulk`, method: 'POST',
headers: {'Content-Type': 'application/json', },
data: reqBulkMapByNames, signal
},
options);
}
export const getBulkMapSupplierItemsMutationOptions = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof bulkMapSupplierItems>>, TError,{supplierId: string;data: ReqBulkMapByNames}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof bulkMapSupplierItems>>, 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<Awaited<ReturnType<typeof bulkMapSupplierItems>>, {supplierId: string;data: ReqBulkMapByNames}> = (props) => {
const {supplierId,data} = props ?? {};
return bulkMapSupplierItems(supplierId,data,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type BulkMapSupplierItemsMutationResult = NonNullable<Awaited<ReturnType<typeof bulkMapSupplierItems>>>
export type BulkMapSupplierItemsMutationBody = ReqBulkMapByNames
export type BulkMapSupplierItemsMutationError = void | HTTPValidationError
/**
* @summary 취급상품 이름 일괄 매핑(엑셀 업로드)
*/
export const useBulkMapSupplierItems = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof bulkMapSupplierItems>>, TError,{supplierId: string;data: ReqBulkMapByNames}, TContext>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof bulkMapSupplierItems>>,
TError,
{supplierId: string;data: ReqBulkMapByNames},
TContext
> => {
const mutationOptions = getBulkMapSupplierItemsMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}
/**
* @summary 취급상품 공급유형 수정
*/
export const updateSupplyType = (
supplierItemId: string,
reqUpdateSupplyType: ReqUpdateSupplyType,
options?: SecondParameter<typeof customFetch>,) => {
return customFetch<ResWebPacketProtocol>(
{url: `/v1/supplier-item/update/${supplierItemId}`, method: 'PATCH',
headers: {'Content-Type': 'application/json', },
data: reqUpdateSupplyType
},
options);
}
export const getUpdateSupplyTypeMutationOptions = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateSupplyType>>, TError,{supplierItemId: string;data: ReqUpdateSupplyType}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof updateSupplyType>>, 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<Awaited<ReturnType<typeof updateSupplyType>>, {supplierItemId: string;data: ReqUpdateSupplyType}> = (props) => {
const {supplierItemId,data} = props ?? {};
return updateSupplyType(supplierItemId,data,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type UpdateSupplyTypeMutationResult = NonNullable<Awaited<ReturnType<typeof updateSupplyType>>>
export type UpdateSupplyTypeMutationBody = ReqUpdateSupplyType
export type UpdateSupplyTypeMutationError = void | HTTPValidationError
/**
* @summary 취급상품 공급유형 수정
*/
export const useUpdateSupplyType = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateSupplyType>>, TError,{supplierItemId: string;data: ReqUpdateSupplyType}, TContext>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof updateSupplyType>>,
TError,
{supplierItemId: string;data: ReqUpdateSupplyType},
TContext
> => {
const mutationOptions = getUpdateSupplyTypeMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}
/**
* @summary 취급상품 매핑 삭제
*/
export const deleteSupplierItem = (
supplierItemId: string,
options?: SecondParameter<typeof customFetch>,) => {
return customFetch<ResWebPacketProtocol>(
{url: `/v1/supplier-item/delete/${supplierItemId}`, method: 'DELETE'
},
options);
}
export const getDeleteSupplierItemMutationOptions = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof deleteSupplierItem>>, TError,{supplierItemId: string}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof deleteSupplierItem>>, 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<Awaited<ReturnType<typeof deleteSupplierItem>>, {supplierItemId: string}> = (props) => {
const {supplierItemId} = props ?? {};
return deleteSupplierItem(supplierItemId,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type DeleteSupplierItemMutationResult = NonNullable<Awaited<ReturnType<typeof deleteSupplierItem>>>
export type DeleteSupplierItemMutationError = void | HTTPValidationError
/**
* @summary 취급상품 매핑 삭제
*/
export const useDeleteSupplierItem = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof deleteSupplierItem>>, TError,{supplierItemId: string}, TContext>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof deleteSupplierItem>>,
TError,
{supplierItemId: string},
TContext
> => {
const mutationOptions = getDeleteSupplierItemMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}

View File

@ -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<HTMLDivElement>(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 = (
<div className="relative">
<Search size={13} className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
id={id}
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={searchPlaceholder}
className="pl-7 text-xs"
autoComplete="off"
/>
{loading && <Loader2 size={13} className="absolute right-2 top-1/2 -translate-y-1/2 animate-spin text-muted-foreground" />}
</div>
);
const list = (
<div className={cn('border border-border rounded bg-background overflow-y-auto', maxListHeight)}>
{loading && options.length === 0 ? (
<div className="flex items-center gap-2 p-3 text-muted-foreground text-[11px]">
<Loader2 size={13} className="animate-spin" />
<Typography as="span" variant="small" className="text-[11px]">불러오는 중…</Typography>
</div>
) : options.length === 0 ? (
<Typography as="p" variant="small" className="p-3 text-muted-foreground text-[11px]">{emptyText}</Typography>
) : (
options.map((opt) => {
const sel = isSelected(opt.id);
return (
<button
key={opt.id}
type="button"
disabled={opt.disabled}
onClick={() => handlePick(opt)}
className={cn(
'w-full flex items-center justify-between gap-2 p-2 text-left border-b border-border last:border-b-0 hover:bg-muted/40 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer transition-colors',
sel && 'bg-primary/5',
)}
>
<span className="min-w-0 flex-1">
{opt.node ?? <Typography as="span" variant="small">{opt.label}</Typography>}
</span>
{multiple ? (
<input type="checkbox" checked={sel} readOnly className="accent-primary h-3.5 w-3.5 shrink-0" />
) : (
sel && <Check size={14} className="text-primary shrink-0" />
)}
</button>
);
})
)}
</div>
);
if (variant === 'inline') {
return (
<div ref={rootRef} className={cn('space-y-1.5', className)}>
{searchInput}
{list}
</div>
);
}
// field: 트리거(선택 요약) + 팝오버(검색창 + 리스트)
const hasSelection = multiple ? values.length > 0 : !!value;
const summary = multiple
? (values.length ? `${values.length}개 선택됨` : placeholder)
: (value ? selectedLabel ?? '선택됨' : placeholder);
return (
<div ref={rootRef} className={cn('relative', className)}>
<button
type="button"
id={id}
onClick={() => setOpen((o) => !o)}
className="w-full flex items-center justify-between gap-2 p-2 bg-background border border-border rounded text-xs text-left hover:bg-muted/20 cursor-pointer"
>
<span className={cn('truncate', !hasSelection && 'text-muted-foreground')}>{summary}</span>
<ChevronsUpDown size={14} className="text-muted-foreground shrink-0" />
</button>
{open && (
<div className="absolute z-50 mt-1 w-full rounded border border-border bg-card shadow-lg p-1.5 space-y-1.5">
{searchInput}
{list}
</div>
)}
</div>
);
}

View File

@ -18,17 +18,26 @@ type RawRow = {
managerName: string; managerName: string;
managerEmail: string; managerEmail: string;
totalRevenue: string; totalRevenue: string;
products: string; // 취급상품 — 상품명 콤마(,) 나열. 업로드 시 매핑테이블로 들어간다.
}; };
type ValidatedRow = RawRow & { status: '정상' | '오류'; message: 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 = { type ExcelUploadModalProps = {
open: boolean; open: boolean;
partners: Partner[]; // 코드 중복 검사용 partners: Partner[]; // 코드 중복 검사용
onConfirm: (rows: SupplierCreate[]) => Promise<BulkFailure[]>; onConfirm: (rows: PartnerUploadRow[]) => Promise<PartnerBulkResult>;
onClose: () => void; onClose: () => void;
}; };
@ -82,8 +91,9 @@ export function downloadPartnerTemplate() {
{ header: '담당자명', value: (r) => r.managerName }, { header: '담당자명', value: (r) => r.managerName },
{ header: '담당자이메일', value: (r) => r.managerEmail }, { header: '담당자이메일', value: (r) => r.managerEmail },
{ header: '총매출액', value: (r) => r.totalRevenue }, { 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['담당자명'] ?? '', managerName: r['담당자명'] ?? '',
managerEmail: r['담당자이메일'] ?? '', managerEmail: r['담당자이메일'] ?? '',
totalRevenue: r['총매출액'] ?? '', totalRevenue: r['총매출액'] ?? '',
products: r['취급상품'] ?? '',
})); }));
setExcelFile(file.name); setExcelFile(file.name);
setRows(loaded); 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))); 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; return;
} }
try { 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 okCount = validRows.length - failures.length;
// 이름이 회사 상품목록에 없어 매핑 못한 취급상품 — 스킵하고 결과에 부기(결정: 미매칭은 스킵+리포트).
const unmatchedNote = unmatchedProducts.length
? ` · 미매칭 취급상품 ${unmatchedProducts.length}건 건너뜀(${unmatchedProducts.slice(0, 5).join(', ')}${unmatchedProducts.length > 5 ? '…' : ''})`
: '';
if (failures.length === 0) { if (failures.length === 0) {
showToast(`총 ${okCount}개 협력사가 서버에 일괄 등록되었습니다.`, 'success'); showToast(`총 ${okCount}개 협력사가 서버에 일괄 등록되었습니다.${unmatchedNote}`, unmatchedProducts.length ? 'info' : 'success');
close(); close();
return; 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)); const okCodes = new Set(validRows.map((r) => r.code).filter((c) => failMap[c] === undefined));
setServerErrors(failMap); setServerErrors(failMap);
setRows((cur) => cur.filter((r) => !okCodes.has(r.code))); setRows((cur) => cur.filter((r) => !okCodes.has(r.code)));
showToast(`${okCount}건 등록 완료 · ${failures.length}건 서버 검증 실패(중복코드 등)`, 'error'); showToast(`${okCount}건 등록 완료 · ${failures.length}건 서버 검증 실패(중복코드 등)${unmatchedNote}`, 'error');
} catch (err) { } catch (err) {
showToast(err instanceof Error ? err.message : '엑셀 일괄 등록 실패', 'error'); showToast(err instanceof Error ? err.message : '엑셀 일괄 등록 실패', 'error');
} }
@ -271,6 +288,7 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp
<TableHead className="p-2 font-semibold">협력사코드 *</TableHead> <TableHead className="p-2 font-semibold">협력사코드 *</TableHead>
<TableHead className="p-2 font-semibold">담당자명 *</TableHead> <TableHead className="p-2 font-semibold">담당자명 *</TableHead>
<TableHead className="p-2 font-semibold">담당자 이메일 *</TableHead> <TableHead className="p-2 font-semibold">담당자 이메일 *</TableHead>
<TableHead className="p-2 font-semibold">취급상품 (,로 구분)</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody className="divide-y divide-border"> <TableBody className="divide-y divide-border">
@ -331,6 +349,15 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp
onChange={(e) => handleUpdateField(row.id, 'managerEmail', e.target.value)} onChange={(e) => handleUpdateField(row.id, 'managerEmail', e.target.value)}
/> />
</TableCell> </TableCell>
<TableCell className="p-2">
<Input
type="text"
className="bg-muted/20 hover:bg-muted/50 text-foreground"
value={row.products}
onChange={(e) => handleUpdateField(row.id, 'products', e.target.value)}
placeholder="상품명, 상품명"
/>
</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>

View File

@ -9,6 +9,7 @@ import { Typography } from '@/components/ui/typography';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Sheet } from '@/components/ui/sheet'; import { Sheet } from '@/components/ui/sheet';
import { SupplierItemsManager } from './SupplierItemsManager';
import { type Partner } from '../types'; import { type Partner } from '../types';
const schema = z.object({ const schema = z.object({
@ -189,6 +190,9 @@ export function PartnerFormSheet({
{errors.managerPhone && <p className="text-[10px] text-rose-500">{errors.managerPhone.message}</p>} {errors.managerPhone && <p className="text-[10px] text-rose-500">{errors.managerPhone.message}</p>}
</div> </div>
{/* 취급상품 관리 — 수정 모드(협력사 확정)에서만. 추가/삭제/유형변경은 즉시 서버 반영. */}
{mode === 'edit' && partner && <SupplierItemsManager supplierId={partner.supplier_id} />}
{/* Buttons wrapper */} {/* Buttons wrapper */}
<div className="pt-4 flex items-center gap-2 border-t border-border mt-8 justify-between"> <div className="pt-4 flex items-center gap-2 border-t border-border mt-8 justify-between">
{mode === 'edit' && partner && ( {mode === 'edit' && partner && (

View File

@ -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 (
<div className="pt-4 border-t border-border space-y-2">
<Typography as="label" variant="label">취급상품 ({items.length})</Typography>
{/* 추가 행 — 상품 + 공급유형 선택 후 추가 */}
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0">
<Combobox
id="supplier-item-pick"
options={options}
loading={catalogQuery.isLoading}
onQueryChange={setQ}
value={pickItemId || undefined}
selectedLabel={pickLabel}
onSelect={(opt) => { setPickItemId(opt.id); setPickLabel(opt.label); }}
placeholder="취급상품으로 추가할 상품 검색..."
searchPlaceholder="상품명·코드로 검색..."
emptyText="일치하는 상품이 없습니다"
/>
</div>
<div className="w-24 shrink-0">
<Select value={pickType} onValueChange={(v) => setPickType(v ?? String(SupplierType.NONE))}>
<SelectTrigger id="supplier-item-pick-type" className="w-full">
<SelectValue>{(value) => supplierTypeLabel(Number(value))}</SelectValue>
</SelectTrigger>
<SelectContent>
{SUPPLIER_TYPE_OPTIONS.map((o) => (
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button type="button" size="sm" onClick={handleAdd} disabled={busy || !pickItemId}>
<Plus />
추가
</Button>
</div>
{/* 현재 취급상품 목록 */}
<div className="border border-border rounded divide-y divide-border max-h-48 overflow-y-auto">
{isLoading ? (
<Typography as="p" variant="small" className="p-3 text-muted-foreground text-[11px]">불러오는 중…</Typography>
) : items.length === 0 ? (
<Typography as="p" variant="small" className="p-3 text-muted-foreground text-[11px]">등록된 취급상품이 없습니다.</Typography>
) : (
items.map((m) => (
<div key={m.supplier_item_id} className="flex items-center justify-between gap-2 p-2">
<div className="min-w-0">
<Typography as="span" variant="small" className="font-semibold block truncate">{m.item_name}</Typography>
{m.item_code && (
<Typography as="span" variant="small" className="text-muted-foreground text-[10px]">{m.item_code}</Typography>
)}
</div>
<div className="flex items-center gap-1.5 shrink-0">
<div className="w-24">
<Select value={String(m.supply_type)} onValueChange={(v) => handleChangeType(m.supplier_item_id, v ?? String(SupplierType.NONE))}>
<SelectTrigger className="w-full">
<SelectValue>{(value) => supplierTypeLabel(Number(value))}</SelectValue>
</SelectTrigger>
<SelectContent>
{SUPPLIER_TYPE_OPTIONS.map((o) => (
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<button
type="button"
onClick={() => handleRemove(m.supplier_item_id)}
title="취급상품 삭제"
className="p-1 rounded text-muted-foreground hover:text-rose-600 hover:bg-rose-500/10 cursor-pointer"
>
<Trash2 size={14} />
</button>
</div>
</div>
))
)}
</div>
</div>
);
}

View File

@ -5,12 +5,14 @@ import {
updateSupplier, updateSupplier,
deleteSupplier, deleteSupplier,
} from '@/api/generated/supplier/supplier'; } from '@/api/generated/supplier/supplier';
import { bulkMapSupplierItems } from '@/api/generated/supplier-item/supplier-item';
import type { ListSuppliersParams } from '@/api/generated/model/listSuppliersParams'; import type { ListSuppliersParams } from '@/api/generated/model/listSuppliersParams';
import type { ReqCreateSupplier } from '@/api/generated/model/reqCreateSupplier'; import type { ReqCreateSupplier } from '@/api/generated/model/reqCreateSupplier';
import type { ReqUpdateSupplier } from '@/api/generated/model/reqUpdateSupplier'; import type { ReqUpdateSupplier } from '@/api/generated/model/reqUpdateSupplier';
import type { ResSupplier } from '@/api/generated/model/resSupplier'; import type { ResSupplier } from '@/api/generated/model/resSupplier';
import type { SupplierData } from '@/api/generated/model/supplierData'; import type { SupplierData } from '@/api/generated/model/supplierData';
import type { BulkFailure } from '@/lib/excel'; import type { BulkFailure } from '@/lib/excel';
import type { PartnerUploadRow, PartnerBulkResult } from '../components/ExcelUploadModal';
import type { Partner } from '../types'; import type { Partner } from '../types';
// 엑셀 중복검사 모달이 참조하는 "전체 협력사"용 메타 쿼리(최대 100건). // 엑셀 중복검사 모달이 참조하는 "전체 협력사"용 메타 쿼리(최대 100건).
@ -54,18 +56,33 @@ export function usePartners(params: ListSuppliersParams) {
}; };
// 엑셀 일괄 등록 — 행별로 순차 생성하되 실패해도 멈추지 않고 사유를 모은다. // 엑셀 일괄 등록 — 행별로 순차 생성하되 실패해도 멈추지 않고 사유를 모은다.
// 서버 DB 검증(중복코드 등)에 걸린 행은 BulkFailure 로 반환 → 모달이 해당 행만 사유와 함께 남긴다. // 서버 DB 검증(중복코드 등)에 걸린 행은 BulkFailure 로 반환 → 모달이 해당 행만 사유와 함께 남긴다.
const bulkCreate = async (rows: ReqCreateSupplier[]): Promise<BulkFailure[]> => { // 등록 성공 시 취급상품(상품명 리스트)을 매핑테이블로 밀어넣는다. 이름 미매칭분은 서버가 스킵하고 돌려줘 리포트한다.
const bulkCreate = async (rows: PartnerUploadRow[]): Promise<PartnerBulkResult> => {
const failures: BulkFailure[] = []; const failures: BulkFailure[] = [];
for (const row of rows) { const unmatched = new Set<string>();
for (const { supplier, products } of rows) {
try { try {
const msg = supplierError(await createSupplier(row)); const res = await createSupplier(supplier);
if (msg) failures.push({ code: row.code ?? '', message: msg }); 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) { } 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(); await refresh();
return failures; return { failures, unmatchedProducts: [...unmatched] };
}; };
// 테이블(현재 페이지) 협력사 + 서버 전체 건수. // 테이블(현재 페이지) 협력사 + 서버 전체 건수.

View File

@ -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 };
}

View File

@ -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 { X, PlusSquare, ArrowRight, Loader2, Gavel } from 'lucide-react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { useGetSupplierLastType } from '@/api/generated/quotation/quotation'; 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 { Button } from '@/components/ui/button';
import { Typography, typographyVariants } from '@/components/ui/typography'; import { Typography, typographyVariants } from '@/components/ui/typography';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; 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 { Product, Partner, QuotationSetting, NegotiationCard } from '../types';
import type { CreateQuotationInput } from '../hooks/useQuotations'; import type { CreateQuotationInput } from '../hooks/useQuotations';
import { QuotationType } from '@/api/generated/model'; import { QuotationType } from '@/api/generated/model';
@ -88,11 +94,71 @@ export function QuotationCreateModal({
}, [renegoSupplierId, prevSupplierType]); }, [renegoSupplierId, prevSupplierType]);
const navigate = useNavigate(); 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 = selItem?.internet_lowest_price ?? null;
const internetLowest = selectedProduct?.internet_lowest_price ?? null; const purchase = selItem?.purchase_price ?? null;
const purchase = selectedProduct?.purchase_price ?? null; const selling = selItem?.selling_price ?? null;
const selling = selectedProduct?.selling_price ?? null;
// 선택 상품의 협력사별 공급유형(제조/유통/총판/없음) — 협력사 리스트에 배지로 덧붙인다(리스트 자체는 재조회 안 함).
const supplyTypeQuery = useListItemSupplyTypes(productId, { query: { enabled: !!productId } });
const supplyTypeBySupplier = useMemo(() => {
const m = new Map<string, number>();
(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: (
<div className="flex items-center justify-between gap-2 w-full">
<div className="min-w-0">
<Typography as="span" variant="small" className="font-semibold block truncate">{s.name}</Typography>
<Typography as="span" variant="small" className="text-muted-foreground">이메일: {s.email}</Typography>
</div>
{productId && <SupplyTypeBadge type={supplyTypeBySupplier.get(s.id)} />}
</div>
),
}));
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: (
<div>
<div className="flex items-center gap-1.5">
<Typography as="span" variant="small" className="text-muted-foreground font-mono block leading-none">{card.code}</Typography>
<span className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${card.isWildcard ? 'bg-amber-50 text-amber-700' : 'bg-zinc-100 text-zinc-600'}`}>
{card.isWildcard ? '와일드' : '협상'}
</span>
</div>
<Typography as="span" variant="small" className="mt-1 block leading-tight">{card.title}</Typography>
</div>
),
}));
// 상품에 산정 후보가 있는지(인터넷=공통, 매입·판매=재 한정). 없으면 MD가가 유일한 후보 → 필수가 된다. // 상품에 산정 후보가 있는지(인터넷=공통, 매입·판매=재 한정). 없으면 MD가가 유일한 후보 → 필수가 된다.
const mdNum = Number(mdPrice) || 0; const mdNum = Number(mdPrice) || 0;
const hasItemCandidate = internetLowest != null || (isReType && (purchase != null || selling != null)); const hasItemCandidate = internetLowest != null || (isReType && (purchase != null || selling != null));
@ -247,25 +313,18 @@ export function QuotationCreateModal({
<div className="space-y-1"> <div className="space-y-1">
<Typography as="label" variant="label">상품</Typography> <Typography as="label" variant="label">상품</Typography>
<Select value={productId} onValueChange={(v) => setProductId(v ?? '')}> <Combobox
<SelectTrigger id="wizard-product" className="w-full"> id="wizard-product"
<SelectValue> options={productOptions}
{(value) => { loading={productSearch.isLoading}
const p = products.find((pp) => pp.id === value); onQueryChange={setProductQ}
return p value={productId || undefined}
? `${p.name} [${p.code}] (기준가: ₩${(p.price ?? 0).toLocaleString()})` selectedLabel={productLabel}
: '협상 대상 상품을 고르세요...'; onSelect={(opt) => { setProductId(opt.id); setProductLabel(opt.label); }}
}} placeholder="협상 대상 상품을 고르세요..."
</SelectValue> searchPlaceholder="상품명·코드로 검색..."
</SelectTrigger> emptyText="일치하는 상품이 없습니다"
<SelectContent> />
{products.filter((p) => p.status === 'ACTIVE').map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name} [{p.code}] (기준가: ₩{(p.price ?? 0).toLocaleString()})
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
{/* MD 제시가 — 입력 시 목표가로 사용. 상품에 다른 후보가 없으면 유일 후보라 필수. */} {/* MD 제시가 — 입력 시 목표가로 사용. 상품에 다른 후보가 없으면 유일 후보라 필수. */}
@ -328,30 +387,19 @@ export function QuotationCreateModal({
{step === 2 && ( {step === 2 && (
<div className="space-y-3"> <div className="space-y-3">
<Typography as="span" variant="label" className="block">협력사 초청 ({oneToOne ? '단일선택' : '다중선택'})</Typography> <Typography as="span" variant="label" className="block">협력사 초청 ({oneToOne ? '단일선택' : '다중선택'})</Typography>
<div className="border border-border rounded overflow-hidden max-h-56 overflow-y-auto divide-y divide-border bg-background"> {/* 서버검색 다중선택 — 각 행에 선택 상품 취급유형 배지(미매핑=미취급). oneToOne이면 togglePartner가 단일로 강제. */}
{partners.map((part) => { <Combobox
const isChecked = selectedPartnerIds.includes(part.id ?? ''); variant="inline"
return ( multiple
<label values={selectedPartnerIds}
key={part.id} options={supplierOptions}
className="flex items-center justify-between p-3 hover:bg-muted/30 cursor-pointer transition-colors" loading={supplierSearch.isLoading}
> onQueryChange={setSupplierQ}
<div className="flex items-center gap-2.5"> onToggle={(opt) => togglePartner(opt.id)}
<input searchPlaceholder="협력사명·코드·담당자 검색..."
type="checkbox" emptyText="협력사가 없습니다"
checked={isChecked} maxListHeight="max-h-56"
onChange={() => togglePartner(part.id ?? '')} />
className="accent-primary h-4 w-4"
/>
<div>
<Typography as="span" variant="small" className="font-semibold block">{part.name}</Typography>
<Typography as="span" variant="small" className="text-muted-foreground">이메일: {part.managerEmail}</Typography>
</div>
</div>
</label>
);
})}
</div>
{/* 협력사 유형 — 항상 노출(처음부터 입력 가능). 재협상(1:1)이면 선택 협력사의 직전 견적 값으로 자동 디폴트. */} {/* 협력사 유형 — 항상 노출(처음부터 입력 가능). 재협상(1:1)이면 선택 협력사의 직전 견적 값으로 자동 디폴트. */}
<div className="space-y-1 pt-3 border-t border-border/40"> <div className="space-y-1 pt-3 border-t border-border/40">
@ -454,35 +502,18 @@ export function QuotationCreateModal({
<Typography as="span" variant="small" className="block text-[10px] text-muted-foreground"> <Typography as="span" variant="small" className="block text-[10px] text-muted-foreground">
1:1 협상에서 AI 협상봇이 발동할 카드입니다. 1:1 협상에서 AI 협상봇이 발동할 카드입니다.
</Typography> </Typography>
<div className="grid grid-cols-2 gap-2 max-h-72 overflow-y-auto"> <Combobox
{cards.filter((c) => !c.isWildcard || c.status === 'ACTIVE').map((card) => { variant="inline"
const isChecked = selectedCardIds.includes(card.id); multiple
return ( values={selectedCardIds}
<div options={cardOptions}
key={card.id} loading={cardSearch.isLoading}
onClick={() => toggleCard(card.id)} onQueryChange={setCardQ}
className={`p-2.5 rounded border cursor-pointer transition-all flex items-start gap-2 ${ onToggle={(opt) => toggleCard(opt.id)}
isChecked ? 'bg-primary/5 border-primary font-bold' : 'bg-background border-border hover:bg-muted/10' searchPlaceholder="카드명·번호·스크립트 검색..."
}`} emptyText="협상카드가 없습니다"
> maxListHeight="max-h-72"
<input type="checkbox" checked={isChecked} readOnly className="accent-primary h-3.5 w-3.5 mt-0.5" /> />
<div>
<div className="flex items-center gap-1.5">
<Typography as="span" variant="small" className="text-muted-foreground font-mono block leading-none">{card.code}</Typography>
<span
className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${
card.isWildcard ? 'bg-amber-50 text-amber-700' : 'bg-zinc-100 text-zinc-600'
}`}
>
{card.isWildcard ? '와일드' : '협상'}
</span>
</div>
<Typography as="span" variant="small" className="mt-1 block leading-tight">{card.title}</Typography>
</div>
</div>
);
})}
</div>
</div> </div>
)} )}
@ -529,6 +560,22 @@ export function QuotationCreateModal({
// ── 헬퍼 컴포넌트 (메인 아래) ────────────────────────────────────────────── // ── 헬퍼 컴포넌트 (메인 아래) ──────────────────────────────────────────────
// 협력사 취급유형 배지 — type undefined = 이 상품 미취급, 그 외 SupplierType 라벨(제조/유통/총판/없음).
function SupplyTypeBadge({ type }: { type?: number }) {
if (type === undefined) {
return (
<Typography as="span" variant="small" className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground shrink-0">
미취급
</Typography>
);
}
return (
<Typography as="span" variant="small" className="text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary font-semibold shrink-0">
{supplierTypeLabel(type)}
</Typography>
);
}
// 세그먼트 컨트롤 — 소수의 명명된 이산 선택(진행 방식·대상)에 라디오보다 명확. 값은 문자열. // 세그먼트 컨트롤 — 소수의 명명된 이산 선택(진행 방식·대상)에 라디오보다 명확. 값은 문자열.
function Segmented({ function Segmented({
options, options,

View File

@ -6,7 +6,7 @@
-- └── negosium_db -- └── negosium_db
-- ├── company : companies, users, user_tokens, notifications -- ├── company : companies, users, user_tokens, notifications
-- ├── supplier : supplier_users, supplier_user_tokens -- ├── 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 -- ├── card : versions, nego_cards, wild_cards, version_nego_cards, version_wild_cards
-- ├── quotation : quotation_settings, quotations -- ├── quotation : quotation_settings, quotations
-- ├── negotiation : sessions, chats, results -- ├── negotiation : sessions, chats, results
@ -185,6 +185,16 @@ CREATE TABLE IF NOT EXISTS partner.item_internet_lowest_prices (
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부 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 : 협상 전략 (버전 / 협상카드 / 와일드카드 / 매핑) -- 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_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_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_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_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_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); 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_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_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_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; -- (협력사,상품) 매핑 중복 방지(소프트 삭제분은 재등록 허용)
-- ============================================================ -- ============================================================