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:
commit
d2710250e0
0
agent/negotiation/chat/infra/__init__.py
Normal file
0
agent/negotiation/chat/infra/__init__.py
Normal file
0
agent/negotiation/chat/infra/repository/__init__.py
Normal file
0
agent/negotiation/chat/infra/repository/__init__.py
Normal file
135
agent/negotiation/chat/infra/repository/nego_context_crud.py
Normal file
135
agent/negotiation/chat/infra/repository/nego_context_crud.py
Normal 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
|
||||
@ -89,9 +89,16 @@ class ChatEngine:
|
||||
if price is None:
|
||||
return self._error(session, "가격을 숫자로 입력해 주세요.")
|
||||
session.context["input_price"] = price
|
||||
# 협력사 첫 제시가 — 가격 수용률(첫 제시가 대비 양보율) 동적 계산의 기준값.
|
||||
session.context.setdefault("first_offer_price", price)
|
||||
session.context["round"] = session.context.get("round", 0) + 1
|
||||
nxt = self._default_next(node)
|
||||
elif mode in _CHOICE_MODES:
|
||||
# 와일드카드 1% 인하 제안을 수락("예")하면 합의가를 제안가(offer_1pct)로 확정한다.
|
||||
# (멘트에만 쓰이던 offer_1pct 가 input_price 에 반영되지 않아, 요약/입찰가가
|
||||
# 직전 제시가로 잡히던 버그 수정 — 수락 시 실제 합의가는 인하가다.)
|
||||
if session.step == "wild_card_1pct" and user_input == "예" and session.context.get("offer_1pct"):
|
||||
session.context["input_price"] = float(session.context["offer_1pct"])
|
||||
nxt = self._choice_next(node, user_input, session)
|
||||
else:
|
||||
nxt = self._default_next(node)
|
||||
|
||||
@ -2,9 +2,11 @@
|
||||
|
||||
인메모리 대신 learning.chat_sessions 에 진행 상태를 저장 → 서버 재시작/멀티워커 안전.
|
||||
company_id 스코프. ChatSession(dataclass) ↔ row 직렬화.
|
||||
인터페이스(IChatSessionRepository) + 구현 형식 — backend crud 패턴 준용(테스트 더블 주입 가능).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
@ -18,7 +20,19 @@ from common.utils.gtime import GTime
|
||||
from negotiation.chat.service.chat_engine import ChatSession
|
||||
|
||||
|
||||
class ChatSessionRepository:
|
||||
class IChatSessionRepository(ABC):
|
||||
@abstractmethod
|
||||
async def get(self, session_id: Optional[str]) -> Optional[ChatSession]:
|
||||
"""세션 조회(자사 company_id 스코프). 없으면 None."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def save(self, session: ChatSession) -> ErrorType:
|
||||
"""진행 상태 upsert(재시작/멀티워커 안전)."""
|
||||
pass
|
||||
|
||||
|
||||
class ChatSessionRepository(IChatSessionRepository):
|
||||
def __init__(self, company_id: str):
|
||||
self.company_id = company_id
|
||||
|
||||
|
||||
102
agent/negotiation/chat/service/negotiation_context_loader.py
Normal file
102
agent/negotiation/chat/service/negotiation_context_loader.py
Normal 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
|
||||
@ -7,7 +7,7 @@
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, asdict
|
||||
from enum import Enum
|
||||
from enum import Enum, IntEnum
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
@ -19,6 +19,29 @@ class NegotiationOutcome(str, Enum):
|
||||
FAILURE = "failure"
|
||||
|
||||
|
||||
class PartnerType(IntEnum):
|
||||
"""파트너사 유형 — 상품 하나를 취급하는 협력사의 경쟁 구조.
|
||||
|
||||
상품별 협력사 수 DB 조회(NegotiationContextLoader)로 세션 시작 시 확정한다:
|
||||
없음=NONE(0), 하나=SINGLE(1), 여러 곳=MULTIPLE(2).
|
||||
값이 협력사 수와 호환되도록 설계됨(0/1/≥2) — snapshot.partner_count 로 그대로 흘러
|
||||
state 버킷(_partner_bucket)과 W 가중치 계산에 쓰인다.
|
||||
"""
|
||||
|
||||
NONE = 0
|
||||
SINGLE = 1
|
||||
MULTIPLE = 2
|
||||
|
||||
@classmethod
|
||||
def from_count(cls, count: int) -> "PartnerType":
|
||||
"""협력사 수 → 유형. 0=NONE, 1=SINGLE, 2 이상=MULTIPLE."""
|
||||
if count <= 0:
|
||||
return cls.NONE
|
||||
if count == 1:
|
||||
return cls.SINGLE
|
||||
return cls.MULTIPLE
|
||||
|
||||
|
||||
@dataclass
|
||||
class NegotiationSnapshot:
|
||||
# --- 이산 상태 산출 입력 ---
|
||||
|
||||
@ -1,11 +1,9 @@
|
||||
import os
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.logger import LOG
|
||||
@ -22,7 +20,7 @@ API_SERVER_START_TIME = GTime.UTCStr()
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# startup: 공유 베이스(_base) 없으면 자동 시드 (운영 자동화, P5)
|
||||
# startup: 공유 베이스(_base) 없으면 자동 시드 (운영 자동화)
|
||||
from bootstrap.lifespan import ensure_base_seeded
|
||||
await ensure_base_seeded()
|
||||
yield
|
||||
@ -58,16 +56,6 @@ async def log_time(request: Request, call_next):
|
||||
async def healthz():
|
||||
return API_SERVER_START_TIME
|
||||
|
||||
|
||||
# 간단 테스트 프론트 (tests/negotiation_demo.html). 같은 출처로 서빙 → CORS 불필요.
|
||||
_DEMO_HTML = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tests", "negotiation_demo.html")
|
||||
|
||||
|
||||
@app.get(path="/demo", include_in_schema=False)
|
||||
async def demo():
|
||||
return FileResponse(_DEMO_HTML)
|
||||
|
||||
|
||||
# 각 도메인 라우터 등록. 새 기능 추가 시 router.v1.<domain>.<file> import 후 include.
|
||||
# (chat / card / learning / qtable 라우터는 P7 에서 Chat_server 14개 API 이식하며 추가)
|
||||
app.include_router(router.v1.health.health.router)
|
||||
|
||||
@ -8,25 +8,22 @@ from common.models.gmodel import Req_WebPacketProtocol, Res_WebPacketProtocol
|
||||
|
||||
|
||||
class Req_Chat(Req_WebPacketProtocol):
|
||||
"""대화 한 턴. session_id 없으면 새 협상 시작(아래 컨텍스트로). tenant 는 헤더로만."""
|
||||
"""대화 한 턴. session_id 없으면 새 협상 시작. tenant 는 헤더로만.
|
||||
|
||||
session_id: Optional[str] = Field(None, description="없으면 새 세션 생성")
|
||||
rq_type: str = Field("재협상", description="재협상 | 재견적")
|
||||
협상 컨텍스트(rq_type/목표가/앵커링가/품목가/매출액/유통코드/파트너 유형)는 요청에 싣지
|
||||
않는다 — 세션 시작 시 agent 가 DB 에서 1회 조회해 확정한다(NegotiationContextLoader):
|
||||
negotiation.sessions(qt_type·target_price·anchoring_price), partner.items(price),
|
||||
partner.suppliers(total_revenue), quotation.quotations(supplier_type), 상품별 협력사 수.
|
||||
행이 없으면(데모/테스트 직접 호출) 기본값 폴백.
|
||||
가격 수용률은 세션 내 라운드별 제시가로 매 턴 동적 계산: max(0, (첫 제시가−현재가)/첫 제시가).
|
||||
"""
|
||||
|
||||
session_id: Optional[str] = Field(None, description="없으면 새 세션 생성. 운영 경로는 negotiation.sessions.session_id 를 그대로 사용")
|
||||
user_input: Optional[str] = Field(None, description="버튼 선택 텍스트 또는 가격(price 모드)")
|
||||
# ① desync 감지: backend 가 보는 직전 봇 step(내부 step 또는 표시 step). 없으면 검사 생략.
|
||||
# agent 는 자기 세션 step 을 정답으로 보고 진행하되, 불일치 시 경고 로깅하고 응답에 desynced 를 실어
|
||||
# backend/front 가 agent 응답의 step/client_step 으로 리싱크하게 한다.
|
||||
client_step: Optional[str] = Field(None, description="backend 가 본 직전 봇 step (desync 감지용)")
|
||||
# 새 세션 시작 시 협상 컨텍스트 (옵션, 기본값 제공)
|
||||
revenue_amount: float = 20_000_000
|
||||
distribution_code: str = "A"
|
||||
partner_count: int = 1
|
||||
acceptance_ratio: float = 0.05
|
||||
# 갑(KT/iMK)이 직접 입력. anchor < target. anchor 기본 제안값 = target*(1-0.01).
|
||||
target_price: int = 10000 # KT 목표 매입가
|
||||
anchor_price: int = 9900 # KT 앵커링가(목표가보다 낮음). 제시가 ≤ anchor → 우선협상
|
||||
# 기존 공급가(품목 기준가). 가격협상_확인 멘트의 인하율(discount_rate) 산출용. 0 이면 인하율 미표시.
|
||||
item_price: int = 0
|
||||
|
||||
|
||||
class Res_Chat(Res_WebPacketProtocol):
|
||||
@ -52,6 +49,9 @@ class Res_Chat(Res_WebPacketProtocol):
|
||||
updated_q: Optional[float] = None
|
||||
visit_count: Optional[int] = None
|
||||
reward_total: Optional[float] = None
|
||||
# 성공 확정 이후 턴(협상완료 요약·협상종료)에 내려주는 합의가. 와일드카드 1% 인하 수락 등
|
||||
# 유저가 직접 입력하지 않은 가격으로 타결될 수 있어, backend 요약/입찰가는 이 값을 최우선 사용한다.
|
||||
settled_price: Optional[int] = None
|
||||
|
||||
|
||||
class Res_ChatSession(Res_WebPacketProtocol):
|
||||
|
||||
@ -14,16 +14,25 @@ from config.server_configs import agent_config
|
||||
from negotiation.chat.service.chat_engine import ChatEngine, ChatSession, StepView
|
||||
from negotiation.chat.service.indicator import compute_indicator
|
||||
from negotiation.chat.service.chat_session_repository import ChatSessionRepository
|
||||
from negotiation.chat.service.negotiation_context_loader import NegotiationContextLoader
|
||||
from negotiation.chat.service.script_repository import ScriptRepository
|
||||
from negotiation.policies.base import EpisodeState, PolicyContext, Transition
|
||||
from negotiation.policy.model_store import QTablePolicyStore
|
||||
from negotiation.qtable.domain.model.snapshot import NegotiationOutcome, NegotiationSnapshot
|
||||
from negotiation.qtable.domain.model.snapshot import NegotiationOutcome, NegotiationSnapshot, PartnerType
|
||||
from negotiation.qtable.domain.service.reward_calculator import RewardCalculator
|
||||
from negotiation.qtable.domain.service.state_calculator import state_index
|
||||
from negotiation.qtable.infra.repository.learning_repository import LearningRepository
|
||||
from router.v1.chat.protocol import Req_Chat, Res_Chat, Res_ChatSession
|
||||
from tenancy.registry import TenantEngine
|
||||
|
||||
# 직접 호출(데모/테스트) 폴백 기본 컨텍스트 — 운영 경로는 NegotiationContextLoader 가
|
||||
# DB(negotiation.sessions·partner.items·partner.suppliers·quotation.quotations)에서 조회한다.
|
||||
_DEFAULT_RQ_TYPE = "재협상"
|
||||
_DEFAULT_TARGET_PRICE = 10000 # KT 목표 매입가
|
||||
_DEFAULT_ANCHOR_PRICE = 9900 # 앵커링가(목표가보다 낮음). 제시가 ≤ anchor → 우선협상
|
||||
_DEFAULT_REVENUE_AMOUNT = 20_000_000 # 매출액(원) — suppliers.total_revenue 미기재 시 폴백
|
||||
_DEFAULT_DISTRIBUTION_CODE = "A" # 유통 코드 — quotations.supplier_type 미지정 시 폴백
|
||||
|
||||
|
||||
class ChatService:
|
||||
async def chat(self, engine: TenantEngine, req: Req_Chat) -> Res_Chat:
|
||||
@ -33,7 +42,11 @@ class ChatService:
|
||||
|
||||
# 1) 세션 확보 / 시작 (DB 영속 — 재시작/멀티워커 안전, P8-A)
|
||||
session = await sess_repo.get(req.session_id) if req.session_id else None
|
||||
chat_engine = ChatEngine(repo, rq_type=(session.rq_type if session else req.rq_type))
|
||||
# 새 세션 컨텍스트: 요청 페이로드 대신 DB(negotiation.sessions 등)에서 1회 조회.
|
||||
# 행이 없으면(데모/테스트 직접 호출) 기본값 폴백.
|
||||
db_ctx = None if session else await NegotiationContextLoader().load(req.session_id)
|
||||
rq_type = session.rq_type if session else (db_ctx.rq_type if db_ctx else _DEFAULT_RQ_TYPE)
|
||||
chat_engine = ChatEngine(repo, rq_type=rq_type)
|
||||
|
||||
# ① step desync 감지: backend 가 본 직전 봇 step(client_step)이 agent 세션 step 과 다르면 경고.
|
||||
# agent 가 자기 step 을 정답으로 보고 진행하고(응답의 step/client_step 으로 backend 가 따라옴),
|
||||
@ -54,14 +67,22 @@ class ChatService:
|
||||
# 새 uuid 발급 없이 그대로 세션 키로 쓴다. 없으면(직접 호출/데모) 생성.
|
||||
session = ChatSession(
|
||||
session_id=req.session_id or str(uuid.uuid4()), tenant_id=engine.tenant_id, company_id=engine.company_id,
|
||||
rq_type=req.rq_type, action_space_size=engine.action_space_size,
|
||||
rq_type=rq_type, action_space_size=engine.action_space_size,
|
||||
context={
|
||||
"revenue_amount": req.revenue_amount, "distribution_code": req.distribution_code,
|
||||
"partner_count": req.partner_count, "acceptance_ratio": req.acceptance_ratio,
|
||||
# 앵커링값은 갑(KT/iMK)이 직접 입력한 값을 사용 (UI 기본값 = target*(1-rate)).
|
||||
"anchor_price": req.anchor_price, "target_price": req.target_price, "round": 0,
|
||||
# 매출액 = suppliers.total_revenue, 유통코드 = quotations.supplier_type 매핑 (loader).
|
||||
# 미기재/미지정이면 기본값 폴백.
|
||||
"revenue_amount": db_ctx.revenue_amount if db_ctx and db_ctx.revenue_amount > 0 else _DEFAULT_REVENUE_AMOUNT,
|
||||
"distribution_code": db_ctx.distribution_code if db_ctx and db_ctx.distribution_code else _DEFAULT_DISTRIBUTION_CODE,
|
||||
# 파트너 유형(PartnerType 값 0/1/2) — 상품별 협력사 수 DB 조회로 세션 시작 시 1회 확정.
|
||||
# snapshot.partner_count 로 그대로 사용(값 호환).
|
||||
# 가격 수용률은 컨텍스트에 두지 않는다 — _snapshot 이 라운드별 제시가로 동적 계산.
|
||||
"partner_count": int(db_ctx.partner_type) if db_ctx else int(PartnerType.SINGLE),
|
||||
# 목표가/앵커링가: sessions 행(생성 시 박제된 anchoring_price) → 박제 ‰ → 1% 폴백 (loader).
|
||||
"anchor_price": db_ctx.anchor_price if db_ctx else _DEFAULT_ANCHOR_PRICE,
|
||||
"target_price": db_ctx.target_price if db_ctx else _DEFAULT_TARGET_PRICE,
|
||||
"round": 0,
|
||||
# 기존 공급가(품목 기준가) — 가격협상_확인 인하율 산출용.
|
||||
"item_price": req.item_price,
|
||||
"item_price": db_ctx.item_price if db_ctx else 0,
|
||||
},
|
||||
)
|
||||
view = chat_engine.start(session)
|
||||
@ -78,6 +99,10 @@ class ChatService:
|
||||
res.chat_end = view.chat_end
|
||||
res.outcome = view.outcome
|
||||
res.desynced = desynced
|
||||
# 합의가: 성공 확정 이후 턴(협상완료 요약 → 협상종료)에 내려준다. 와일드카드 수락처럼
|
||||
# 유저가 직접 입력하지 않은 가격으로 타결될 수 있어 backend 요약/입찰가는 이 값을 최우선으로 쓴다.
|
||||
if session.context.get("final_outcome") == "success" and session.context.get("input_price"):
|
||||
res.settled_price = int(session.context["input_price"])
|
||||
|
||||
# 3) 학습 결합 (가격협상 카드선택 → 카드 스크립트·협상지표 / 종료 보상)
|
||||
if view.error is None and engine.action_space_size > 0:
|
||||
@ -112,11 +137,24 @@ class ChatService:
|
||||
return res
|
||||
|
||||
# ---- 학습 ----------------------------------------------------------
|
||||
@staticmethod
|
||||
def _acceptance_ratio(context: dict) -> float:
|
||||
"""가격 수용률 동적 계산 — 협력사 첫 제시가 대비 현재 제시가의 양보율 (설계서 공식).
|
||||
|
||||
acceptance = max(0, (첫 제시가 − 현재 제시가) / 첫 제시가).
|
||||
첫 제시 라운드(양보 없음)·첫 제시가 미기록(과거 세션 호환)이면 0(low 버킷).
|
||||
"""
|
||||
first = context.get("first_offer_price") or 0
|
||||
current = context.get("input_price") or 0
|
||||
if first <= 0 or current <= 0:
|
||||
return 0.0
|
||||
return max(0.0, (first - current) / first)
|
||||
|
||||
def _snapshot(self, session: ChatSession, outcome: NegotiationOutcome) -> NegotiationSnapshot:
|
||||
c = session.context
|
||||
return NegotiationSnapshot(
|
||||
revenue_amount=c["revenue_amount"], distribution_code=c["distribution_code"],
|
||||
partner_count=c["partner_count"], acceptance_ratio=c["acceptance_ratio"],
|
||||
partner_count=c["partner_count"], acceptance_ratio=self._acceptance_ratio(c),
|
||||
input_price=c.get("input_price", c["anchor_price"]), anchor_price=c["anchor_price"],
|
||||
target_price=c["target_price"], round_number=c.get("round", 0), outcome=outcome,
|
||||
)
|
||||
|
||||
@ -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>
|
||||
@ -33,8 +33,8 @@ async def test_4_1_honors_backend_session_id(db_engine):
|
||||
svc = ChatService()
|
||||
|
||||
# 첫 턴: backend 의 session_id 를 그대로 키로 써야 함 (새 uuid 발급 X)
|
||||
r = await svc.chat(eng, Req_Chat(session_id=BACKEND_SESSION_ID, rq_type="재협상",
|
||||
target_price=10000, anchor_price=9900))
|
||||
# 컨텍스트는 DB 조회(행 없음 → 기본값 폴백: target=10000, anchor=9900)
|
||||
r = await svc.chat(eng, Req_Chat(session_id=BACKEND_SESSION_ID))
|
||||
assert r.session_id == BACKEND_SESSION_ID
|
||||
assert r.step == "서비스안내"
|
||||
|
||||
@ -62,8 +62,7 @@ async def test_4_4_company_id_chat_end_to_end(db_engine):
|
||||
reset_sessions()
|
||||
eng = await _reg().get_engine(COMPANY_ID) # 자동 온보딩 테넌트
|
||||
svc = ChatService()
|
||||
r = await svc.chat(eng, Req_Chat(session_id=BACKEND_SESSION_ID, rq_type="재협상",
|
||||
target_price=10000, anchor_price=9900))
|
||||
r = await svc.chat(eng, Req_Chat(session_id=BACKEND_SESSION_ID))
|
||||
assert r.session_id == BACKEND_SESSION_ID and r.step == "서비스안내"
|
||||
# 카드선택 턴까지 진행 → company_id 스코프로 학습 기록
|
||||
for ui in ["확인", "예", "확인", "11000", "예"]:
|
||||
|
||||
202
agent/tests/test_context_loader.py
Normal file
202
agent/tests/test_context_loader.py
Normal 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"
|
||||
@ -31,7 +31,7 @@ async def _run(svc, eng, turns):
|
||||
"""turns: user_input 리스트(첫 None=시작). 반환: 응답 리스트."""
|
||||
sid, out = None, []
|
||||
for ui in turns:
|
||||
r = await svc.chat(eng, Req_Chat(session_id=sid, user_input=ui, rq_type="재협상"))
|
||||
r = await svc.chat(eng, Req_Chat(session_id=sid, user_input=ui))
|
||||
sid = r.session_id
|
||||
out.append(r)
|
||||
if r.chat_end:
|
||||
@ -76,6 +76,48 @@ async def test_full_conversation_reaches_completion(db_engine):
|
||||
assert cnt >= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wildcard_1pct_accept_settles_at_offer_price(db_engine):
|
||||
"""와일드카드 1% 인하 제안을 '예'로 수락하면 합의가는 인하 제안가(offer_1pct)여야 한다.
|
||||
|
||||
회귀: 수락해도 input_price 가 직전 제시가로 남아 요약/입찰가가 원래 가격(예: 20000)으로
|
||||
잡히던 버그 — settled_price 가 인하가(예: 19800)로 내려와야 한다.
|
||||
"""
|
||||
reset_sessions()
|
||||
eng = await _reg().get_engine("ktcommerce")
|
||||
svc = ChatService()
|
||||
# anchor=9900(기본). 10000 은 anchor*1.02(10098) 이내 → wild_card_1pct 발동, offer_1pct=9900.
|
||||
out = await _run(svc, eng, [None, "확인", "예", "확인", "10000", "예", "예",
|
||||
"협상 내용을 확인했으며, 이의가 없음에 동의합니다."])
|
||||
steps = [r.step for r in out]
|
||||
assert "wild_card_1pct" in steps
|
||||
|
||||
# 수락 후 협상완료(요약 턴)·협상종료(종료 턴) 모두 합의가 = 인하 제안가 9900
|
||||
done = [r for r in out if r.step in ("협상완료", "협상종료")]
|
||||
assert done and all(r.settled_price == 9900 for r in done)
|
||||
|
||||
|
||||
def test_partner_type_enum_mapping():
|
||||
"""PartnerType 계약값(0=NONE/1=SINGLE/2=MULTIPLE)과 수→유형 매핑."""
|
||||
from negotiation.qtable.domain.model.snapshot import PartnerType
|
||||
|
||||
assert PartnerType.NONE == 0 and PartnerType.SINGLE == 1 and PartnerType.MULTIPLE == 2
|
||||
assert PartnerType.from_count(0) is PartnerType.NONE
|
||||
assert PartnerType.from_count(1) is PartnerType.SINGLE
|
||||
assert PartnerType.from_count(2) is PartnerType.MULTIPLE
|
||||
assert PartnerType.from_count(7) is PartnerType.MULTIPLE
|
||||
|
||||
|
||||
def test_acceptance_ratio_dynamic_calc():
|
||||
"""가격 수용률 동적 계산 — 첫 제시가 대비 양보율. 첫 제시/미기록=0, 인상 시 0 클립."""
|
||||
calc = ChatService._acceptance_ratio
|
||||
assert calc({}) == 0.0 # 가격 입력 전
|
||||
assert calc({"first_offer_price": 11000, "input_price": 11000}) == 0.0 # 첫 제시(양보 0)
|
||||
assert calc({"first_offer_price": 11000, "input_price": 10200}) == pytest.approx(800 / 11000)
|
||||
assert calc({"first_offer_price": 10000, "input_price": 12000}) == 0.0 # 인상(비정상) → 0
|
||||
assert calc({"input_price": 9800}) == 0.0 # 과거 세션 호환(첫 제시가 미기록)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_priority_completes_without_wildcard(db_engine):
|
||||
reset_sessions()
|
||||
@ -95,7 +137,7 @@ async def test_tenant_brand_isolation(db_engine):
|
||||
reset_sessions()
|
||||
svc = ChatService()
|
||||
reg = _reg()
|
||||
ik = await svc.chat(await reg.get_engine("imarketkorea"), Req_Chat(session_id=None, rq_type="재협상"))
|
||||
ik = await svc.chat(await reg.get_engine("imarketkorea"), Req_Chat(session_id=None))
|
||||
assert "데모상사 B" in ik.script
|
||||
|
||||
|
||||
|
||||
@ -27,19 +27,19 @@ async def test_session_persists_and_resumes_across_instances(db_engine):
|
||||
reg = _eng()
|
||||
eng = await reg.get_engine("ktcommerce")
|
||||
|
||||
# 인스턴스 1: 협상 시작 + 몇 턴 진행
|
||||
# 인스턴스 1: 협상 시작 + 몇 턴 진행 (컨텍스트는 DB 조회 — 행이 없으므로 기본값 폴백)
|
||||
svc1 = ChatService()
|
||||
r = await svc1.chat(eng, Req_Chat(rq_type="재협상", target_price=10000, anchor_price=8000))
|
||||
r = await svc1.chat(eng, Req_Chat())
|
||||
sid = r.session_id
|
||||
for ui in ["확인", "예", "확인", "11000"]:
|
||||
r = await svc1.chat(eng, Req_Chat(session_id=sid, user_input=ui))
|
||||
step_before = r.step
|
||||
assert step_before == "가격협상_확인" # 11000 제시 후 확인 단계
|
||||
|
||||
# DB 에 저장됐는지 확인
|
||||
# DB 에 저장됐는지 확인 (기본 컨텍스트: anchor=9900)
|
||||
saved = await ChatSessionRepository(eng.company_id).get(sid)
|
||||
assert saved is not None and saved.step == step_before
|
||||
assert saved.context["anchor_price"] == 8000
|
||||
assert saved.context["anchor_price"] == 9900
|
||||
|
||||
# 인스턴스 2 (재시작/다른 워커 모사): 같은 session_id 로 이어가기
|
||||
svc2 = ChatService()
|
||||
@ -53,7 +53,7 @@ async def test_session_company_scoped(db_engine):
|
||||
reg = _eng()
|
||||
eng = await reg.get_engine("ktcommerce")
|
||||
svc = ChatService()
|
||||
r = await svc.chat(eng, Req_Chat(rq_type="재협상"))
|
||||
r = await svc.chat(eng, Req_Chat())
|
||||
sid = r.session_id
|
||||
|
||||
# 자사(ktcommerce)로는 조회됨
|
||||
|
||||
@ -32,26 +32,24 @@ class AgentTurn:
|
||||
indicator_value: Optional[float] = None
|
||||
# agent 가 직접 내려주는 표현 폼(summaryRSP/CM·rejectRSP/CM·indicator). 없으면 backend 가 step+qt_type 으로 폴백.
|
||||
bot_chat_type: Optional[str] = None
|
||||
# 성공 확정 이후 턴에 agent 가 내려주는 합의가. 와일드카드 1% 인하 수락처럼 유저가 직접
|
||||
# 입력하지 않은 가격으로 타결될 수 있어, 요약 표시가·입찰가 확정 시 이 값을 최우선 사용한다.
|
||||
settled_price: Optional[int] = None
|
||||
ok: bool = True # agent 호출 성공 여부 (False 면 CHAT_AGENT_UNAVAILABLE)
|
||||
timed_out: bool = False # 타임아웃 여부. True 면 agent 가 이미 진행했을 수 있어 desync 위험 → 별도 처리.
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentChatContext:
|
||||
"""새 세션 시작 시 agent 에 주입하는 협상 컨텍스트. 기존 세션이면 user_input 만 의미 있다."""
|
||||
"""agent 호출 컨텍스트. 협상 컨텍스트(목표가/앵커/품목가/매출액/유통코드/파트너 유형/수용률)는
|
||||
더 이상 전송하지 않는다 — agent 가 session_id 로 DB(negotiation.sessions 등)에서 직접
|
||||
조회·계산한다(Req_Chat 슬림화). rq_type/target_price 는 backend 자체 로직(표현 폴백·테스트
|
||||
더블)용으로만 유지하며 전송되지 않는다.
|
||||
"""
|
||||
|
||||
tenant_id: str # X-Tenant-ID = 견적(갑) 회사 company_id
|
||||
rq_type: str = "재협상" # 재협상 | 재견적
|
||||
target_price: int = 0 # 갑 목표 매입가(원)
|
||||
anchor_price: int = 0 # 앵커링가(목표가보다 낮음). 세션 생성 시 박제된 sessions.anchoring_price.
|
||||
item_price: int = 0 # 기존 공급가(품목 기준가). agent 가격협상_확인 인하율 산출용.
|
||||
# 핸드오프 #4: agent 의 RL 상태(state) 계산 입력.
|
||||
# partner_count 는 견적당 세션 수로 산출(실데이터). 나머지 3개는 우리 스키마에 데이터 소스가 없어
|
||||
# 기본값으로 보낸다 → agent 가 실제값을 받으려면 backend 스키마에 컬럼 추가 필요(HANDOFF.md ④).
|
||||
revenue_amount: float = 20_000_000 # 매출액(원) — DB 소스 없음(기본값)
|
||||
distribution_code: str = "A" # 유통 코드(agent config code_map 키) — DB 소스 없음(기본값)
|
||||
partner_count: int = 1 # 공급사 수 — 견적당 세션 수로 산출
|
||||
acceptance_ratio: float = 0.05 # 가격 수용률 0~1 — DB 소스 없음(기본값)
|
||||
rq_type: str = "재협상" # 재협상(1:1) | 재견적(1:N) — backend 로컬 용도
|
||||
target_price: int = 0 # 갑 목표 매입가(원) — backend 로컬 용도
|
||||
# 핸드오프 #1: backend 가 보는 현재 step(직전 봇 step). agent 가 자기 세션 step 과 대조해 desync 감지에 쓸 수 있다.
|
||||
client_step: Optional[str] = None
|
||||
extra: dict = field(default_factory=dict)
|
||||
@ -72,16 +70,9 @@ class HttpAgentClient(IAgentClient):
|
||||
|
||||
body = {
|
||||
"session_id": session_id, # 핸드오프 #1: agent 가 이 값을 세션 키로 그대로 사용해야 함
|
||||
"rq_type": ctx.rq_type,
|
||||
# 협상 컨텍스트(rq_type/목표가/앵커/품목가/매출액/유통코드/파트너 유형/수용률)는 보내지
|
||||
# 않는다 — agent 가 session_id 로 DB 에서 직접 조회·계산한다(NegotiationContextLoader).
|
||||
"user_input": user_input,
|
||||
"target_price": ctx.target_price,
|
||||
"anchor_price": ctx.anchor_price,
|
||||
"item_price": ctx.item_price,
|
||||
# 핸드오프 #4: RL state 입력 (agent Req_Chat 이 받는 필드). 현재 기본값.
|
||||
"revenue_amount": ctx.revenue_amount,
|
||||
"distribution_code": ctx.distribution_code,
|
||||
"partner_count": ctx.partner_count,
|
||||
"acceptance_ratio": ctx.acceptance_ratio,
|
||||
# 핸드오프 #1: backend 가 보는 직전 step. agent 가 desync 감지에 사용(미구현 시 무시됨).
|
||||
"client_step": ctx.client_step,
|
||||
}
|
||||
@ -111,6 +102,7 @@ class HttpAgentClient(IAgentClient):
|
||||
card_id=data.get("card_id"),
|
||||
indicator_value=data.get("indicator_value"),
|
||||
bot_chat_type=data.get("bot_chat_type"),
|
||||
settled_price=data.get("settled_price"),
|
||||
ok=True,
|
||||
)
|
||||
|
||||
|
||||
@ -361,7 +361,9 @@ class ChatService:
|
||||
summary = None
|
||||
if bot_chat_type in ("summaryRSP", "summaryCM"):
|
||||
final_price = price if price is not None else (sess.bid_price or sess.target_price)
|
||||
summary = await self._build_summary(sess, quote, item, final_price, last_price)
|
||||
# 합의가는 agent 가 내려준 settled_price 최우선 (와일드카드 1% 인하 수락 등
|
||||
# 유저 미입력 가격 타결 케이스 — 마지막 유저 제시가와 다를 수 있다).
|
||||
summary = await self._build_summary(sess, quote, item, final_price, turn.settled_price or last_price)
|
||||
|
||||
# 봇 메시지 + 종료 시 확정(성공=DONE+입찰가 / 실패=REJECTED+거부사유·제시가). 한 트랜잭션.
|
||||
bot_msg = self._build_bot_chat(sess, seq=max_seq + 2, turn=turn, bot_chat_type=bot_chat_type, summary=summary)
|
||||
@ -374,10 +376,11 @@ class ChatService:
|
||||
if turn.chat_end:
|
||||
if turn.outcome == "success":
|
||||
new_status = SessionStatus.DONE.value
|
||||
# 입찰가 = 이번 턴 가격(보통 None) → 마지막 제시가 → 목표가 순으로 확정.
|
||||
bid = price if price is not None else (last_price if last_price else sess.target_price)
|
||||
# 입찰가 = agent 합의가(settled_price, 와일드카드 수락 등) → 이번 턴 가격(보통 None)
|
||||
# → 마지막 제시가 → 목표가 순으로 확정.
|
||||
bid = turn.settled_price or (price if price is not None else (last_price if last_price else sess.target_price))
|
||||
# ⑤ 협상된 제시가가 하나도 없어 목표가로 폴백하면, 합의가가 실제 협상과 다를 수 있어 경고.
|
||||
if price is None and not last_price:
|
||||
if not turn.settled_price and price is None and not last_price:
|
||||
LOG.w(f"[chat] 합의가 폴백→목표가 session_id={sess.session_id} bid={bid} "
|
||||
f"— 협상 중 가격 제시가 기록되지 않음(프론트 user_input_type='price' 누락 의심)")
|
||||
funcs.append(lambda s: self.chat_crud.finalize_session(s, sess.session_id, new_status, bid_price=bid))
|
||||
@ -417,52 +420,18 @@ class ChatService:
|
||||
tenant_id = str(item.company_id)
|
||||
else:
|
||||
LOG.w(f"[chat] tenant_id 해석 실패(item.company_id 없음) session_id={sess.session_id} — agent 400 위험")
|
||||
rq_type = "재협상" if sess.qt_type == 1 else "재견적"
|
||||
# 1:1 견적유형(1=renego, 3=new_nego) → 재협상, 1:N(2=requote, 4=new_quote) → 재견적.
|
||||
# agent 도 sessions.qt_type 으로 동일 판별(NegotiationContextLoader) — backend 로컬 용도.
|
||||
rq_type = "재협상" if sess.qt_type in (1, 3) else "재견적"
|
||||
target_price = int(sess.target_price or 0)
|
||||
# 앵커가: 세션 생성 시 박제된 값(anchoring_price)을 그대로 사용 — 협상 중 불변.
|
||||
anchor = await self._resolve_anchor_price(sess, target_price)
|
||||
# 공급사 수: 같은 견적에 속한 세션 수(재협상=1, 재견적=N). agent partner 차원(single/multiple/none) 입력.
|
||||
partner_count = await self._count_partners(sess)
|
||||
# revenue_amount / distribution_code / acceptance_ratio 는 현재 스키마에 데이터 소스가 없어
|
||||
# AgentChatContext 기본값으로 보낸다(HANDOFF #4 — 컬럼 추가/소스 합의 필요).
|
||||
# 기존 공급가(품목 기준가) — agent 가격협상_확인 인하율(discount_rate) 산출 입력.
|
||||
item_price = int(item.price) if item is not None and item.price else 0
|
||||
# 협상 컨텍스트(앵커가/품목가/매출액/유통코드/파트너 유형/수용률)는 더 이상 계산·전송하지 않는다 —
|
||||
# agent 가 session_id 로 DB(negotiation.sessions·partner.items/suppliers·quotations)에서 직접 조회한다.
|
||||
# (앵커가 박제·무할인 폴백 정책 — schedules/anchoring/docs/개발용.md §9.2 — 은 agent loader 가 승계.)
|
||||
return AgentChatContext(
|
||||
tenant_id=tenant_id, rq_type=rq_type,
|
||||
target_price=target_price, anchor_price=anchor, partner_count=partner_count,
|
||||
item_price=item_price, client_step=client_step,
|
||||
target_price=target_price, client_step=client_step,
|
||||
)
|
||||
|
||||
async def _resolve_anchor_price(self, sess, target_price: int) -> int:
|
||||
"""세션에 박제된 앵커가(anchoring_price — negodata 가 생성 시 기록)를 그대로 사용.
|
||||
|
||||
박제값 사용이 정상 경로다: 협상 진행 중 앵커링 배치 조정·재기동이 껴도 앵커가 흔들리지 않는다
|
||||
("제안 당시 값" 판정의 전제 — schedules/anchoring/docs/개발용.md §9.2). backend 는 앵커를 계산하지 않는다.
|
||||
박제가 없으면(데이터 이상 — 사실상 발생하지 않음) 무할인 폴백 anchor=target + WARN.
|
||||
이때 박제하지 않으므로 해당 세션은 앵커링 집계에서 자동 제외(EXCLUDED)된다 — 학습 무오염.
|
||||
"""
|
||||
if not target_price:
|
||||
return 0
|
||||
if sess.anchoring_price is not None:
|
||||
return int(sess.anchoring_price)
|
||||
LOG.w(f"[chat] 앵커가 박제 없음 session_id={sess.session_id} — 무할인 폴백(anchor=target), 집계 제외")
|
||||
return target_price
|
||||
|
||||
async def _count_partners(self, sess) -> int:
|
||||
"""같은 견적(quotation_id)에 속한 협상 세션 수 = 참여 공급사 수. 실패 시 1 폴백."""
|
||||
def _q(s):
|
||||
stmt = (
|
||||
select(func.count())
|
||||
.select_from(sessions)
|
||||
.where(sessions.quotation_id == sess.quotation_id, sessions.deleted == False) # noqa: E712
|
||||
)
|
||||
return DB_SESSION_MNG.execute(s, stmt)
|
||||
|
||||
err_type, rows = await DB_SESSION_MNG.execute_lambda(sessions.DBType(), DBWRType.DB_READ.value, _q)
|
||||
if err_type != ErrorType.SUCCESS or not rows or not rows[0]:
|
||||
return 1
|
||||
return int(rows[0])
|
||||
|
||||
async def _last_user_price(self, sess) -> Optional[int]:
|
||||
"""세션에서 가장 최근 유저 제시가(negotiation.chats.target_price>0). 없으면 None."""
|
||||
def _q(s):
|
||||
|
||||
@ -32,15 +32,17 @@ class _AnchorAgent(IAgentClient):
|
||||
"""결정론적 더블: 서비스안내(오프닝) → 가격 입력 요청 → 합의 종료.
|
||||
|
||||
앵커보다 높은 가격이면 같은 step 을 반복(마지막 제시가 덮어쓰기 검증용).
|
||||
매 턴 수신한 ctx.anchor_price 를 기록해 backend 의 앵커 해석을 관찰한다.
|
||||
(script 에 앵커가 보이는 건 테스트 관찰 편의일 뿐 — 실제 agent 는 비노출.)
|
||||
앵커 해석(박제값 소비/무할인 폴백)은 agent 가 DB 에서 직접 수행하도록 이관됐다
|
||||
(NegotiationContextLoader — agent tests/test_context_loader.py 가 검증). 여기 더블은
|
||||
자체 앵커 상수로 수락 여부만 판정하고, backend 가 보내는 ctx.target_price 를 관찰한다.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.seen_anchors: list[int] = []
|
||||
def __init__(self, anchor: int = ANCHOR):
|
||||
self.anchor = anchor
|
||||
self.seen_targets: list[int] = []
|
||||
|
||||
async def chat(self, session_id, user_input, ctx) -> AgentTurn:
|
||||
self.seen_anchors.append(ctx.anchor_price)
|
||||
self.seen_targets.append(ctx.target_price)
|
||||
sid = session_id or "fake-session"
|
||||
if user_input is None: # 오프닝(턴0)
|
||||
return AgentTurn(session_id=sid, step="서비스안내", client_step="서비스안내",
|
||||
@ -48,10 +50,10 @@ class _AnchorAgent(IAgentClient):
|
||||
input_options=["네, 시작할게요"])
|
||||
if ctx.client_step == "서비스안내":
|
||||
return AgentTurn(session_id=sid, step="기존가격제시", client_step="기존가격제시",
|
||||
script=f"저희가 제안드리는 첫 목표 가격은 {ctx.anchor_price}원입니다. "
|
||||
script=f"저희가 제안드리는 첫 목표 가격은 {self.anchor}원입니다. "
|
||||
f"제안하실 가격을 입력해 주세요.", input_mode="price")
|
||||
price = _parse_price(user_input)
|
||||
if price is not None and price <= ctx.anchor_price:
|
||||
if price is not None and price <= self.anchor:
|
||||
return AgentTurn(session_id=sid, step="협상종료", client_step="협상종료",
|
||||
script=f"{price:,}원으로 합의되었습니다.", chat_end=True, outcome="success")
|
||||
return AgentTurn(session_id=sid, step="기존가격제시", client_step="기존가격제시",
|
||||
@ -159,7 +161,7 @@ async def test_snapshot_consumed_and_last_offer_recorded(client, db_engine, anch
|
||||
assert r.status_code == 200 and r.json()["message"]["step"] == "기존가격제시"
|
||||
row = await _anchor_columns(db_engine, sid)
|
||||
assert row.last_offer_price is None
|
||||
assert _fake_agent.seen_anchors[-1] == ANCHOR # backend 가 박제값을 그대로 전달
|
||||
assert _fake_agent.seen_targets[-1] == TARGET # backend 로컬 컨텍스트(target) 전달 확인
|
||||
|
||||
r = await _send(client, token, sid, "99,500", "price") # 앵커 초과 → 같은 step 반복
|
||||
assert r.json()["message"]["step"] == "기존가격제시"
|
||||
@ -175,7 +177,8 @@ async def test_snapshot_consumed_and_last_offer_recorded(client, db_engine, anch
|
||||
assert (row.anchoring_price, row.anchoring_value) == (ANCHOR, 10) # 박제 불변
|
||||
|
||||
|
||||
# ── 폴백 경로: 박제 NULL → 무할인(anchor=target) + 미박제 유지 ──
|
||||
# ── 폴백 경로: 박제 NULL 세션도 backend 채팅 경로가 정상 동작 + 미박제 유지 ──
|
||||
# (무할인 폴백 anchor=target 자체는 agent NegotiationContextLoader 가 수행/검증 — agent 테스트 소관)
|
||||
async def test_null_snapshot_falls_back_to_target(client, db_engine, anchor_seed, _fake_agent):
|
||||
sid = str(anchor_seed["sids"]["N"])
|
||||
token = await _login_token(client)
|
||||
@ -183,11 +186,13 @@ async def test_null_snapshot_falls_back_to_target(client, db_engine, anchor_seed
|
||||
await _messages(client, token, sid)
|
||||
r = await _send(client, token, sid, "네, 시작할게요")
|
||||
assert r.json()["message"]["step"] == "기존가격제시"
|
||||
assert _fake_agent.seen_anchors[-1] == TARGET # 무할인 폴백: anchor = target
|
||||
|
||||
r = await _send(client, token, sid, "97,000", "price") # 가격 입력(폴백 앵커 이하 → 종료)
|
||||
r = await _send(client, token, sid, "97,000", "price") # 가격 입력(더블 앵커 이하 → 종료)
|
||||
assert r.json()["session_status"] == 3
|
||||
row = await _anchor_columns(db_engine, sid)
|
||||
assert (row.anchoring_price, row.anchoring_value) == (None, None) # 미박제 유지(집계 제외 조건)
|
||||
assert row.bid_price == 97_000
|
||||
row = await _anchor_columns(db_engine, sid)
|
||||
assert row.anchoring_price is None # backend 는 박제하지 않음(앵커 없음 → 집계 제외)
|
||||
assert row.anchoring_value is None
|
||||
assert row.last_offer_price == 97_000 # 가격 흔적 기록은 정상 동작
|
||||
|
||||
@ -124,7 +124,19 @@ class suppliers(MainTableMixin, MAIN_BASE):
|
||||
manager_name = Column(String(50), nullable=True)
|
||||
manager_email = Column(String(255), nullable=True)
|
||||
manager_contact_number = Column(String(20), nullable=True) # ERD 오타(manger) 교정
|
||||
total_revenue = Column(BigInteger, nullable=True) # 총매출액(원)
|
||||
total_revenue = Column(BigInteger, nullable=True) # 총매출액
|
||||
|
||||
|
||||
class supplier_items(MainTableMixin, MAIN_BASE):
|
||||
__tablename__ = "supplier_items"
|
||||
# (supplier_id, item_id) 유일성은 soft-delete 인지 부분 유니크 인덱스(uq_supplier_items, WHERE deleted=FALSE)로 DB에서 보장.
|
||||
__table_args__ = {"schema": "partner"}
|
||||
|
||||
supplier_item_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
supplier_id = Column(UUID(as_uuid=True), nullable=False, index=True) # suppliers.supplier_id
|
||||
item_id = Column(UUID(as_uuid=True), nullable=False, index=True) # items.item_id
|
||||
# SupplierType: 이 협력사가 이 상품을 공급하는 방식(0=없음/1=유통/2=제조/3=총판). quotations.supplier_type 와 값은 같으나 의미 단위가 (협력사,상품)이라 컬럼명은 supply_type.
|
||||
supply_type = Column(SmallInteger, nullable=False, server_default=text("0"), default=0)
|
||||
|
||||
|
||||
class nego_cards(MainTableMixin, MAIN_BASE):
|
||||
|
||||
@ -57,6 +57,7 @@ class ErrorType(Enum):
|
||||
# 협력사 관련 에러
|
||||
SUPPLIER_NOT_FOUND = 1400
|
||||
SUPPLIER_CODE_DUPLICATE = auto()
|
||||
SUPPLIER_ITEM_NOT_FOUND = auto() # 협력사-상품 매핑 미존재
|
||||
|
||||
# 견적 관련 에러
|
||||
QUOTATION_NOT_FOUND = 1500
|
||||
|
||||
175
negodata/backend/crud/supplier_item_crud.py
Normal file
175
negodata/backend/crud/supplier_item_crud.py
Normal 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
|
||||
@ -14,6 +14,7 @@ import router.v1.auth.account
|
||||
import router.v1.company.user
|
||||
import router.v1.item.item
|
||||
import router.v1.supplier.supplier
|
||||
import router.v1.supplier_item.supplier_item
|
||||
import router.v1.card.card
|
||||
import router.v1.quotation.quotation
|
||||
import router.v1.quotation_setting.quotation_setting
|
||||
@ -68,6 +69,7 @@ app.include_router(router.v1.auth.account.router)
|
||||
app.include_router(router.v1.company.user.router)
|
||||
app.include_router(router.v1.item.item.router)
|
||||
app.include_router(router.v1.supplier.supplier.router)
|
||||
app.include_router(router.v1.supplier_item.supplier_item.router)
|
||||
app.include_router(router.v1.card.card.router)
|
||||
app.include_router(router.v1.quotation.quotation.router)
|
||||
app.include_router(router.v1.quotation_setting.quotation_setting.router)
|
||||
|
||||
60
negodata/backend/router/v1/supplier_item/protocol.py
Normal file
60
negodata/backend/router/v1/supplier_item/protocol.py
Normal 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] = [] # 회사 상품 목록에 이름이 없어 매핑 못한 입력명들
|
||||
61
negodata/backend/router/v1/supplier_item/supplier_item.py
Normal file
61
negodata/backend/router/v1/supplier_item/supplier_item.py
Normal 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)))
|
||||
264
negodata/backend/services/supplier_item_service.py
Normal file
264
negodata/backend/services/supplier_item_service.py
Normal 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
|
||||
@ -69,6 +69,7 @@ export * from './itemDataSellingPrice';
|
||||
export * from './itemDataSpec';
|
||||
export * from './itemDataUpdatedAt';
|
||||
export * from './itemDataVatYn';
|
||||
export * from './itemSupplyType';
|
||||
export * from './listCardsParams';
|
||||
export * from './listItemsParams';
|
||||
export * from './listNotificationsParams';
|
||||
@ -119,6 +120,7 @@ export * from './quotationSettingDataUpdatedAt';
|
||||
export * from './quotationSettingDataUserId';
|
||||
export * from './quotationStatus';
|
||||
export * from './quotationType';
|
||||
export * from './reqBulkMapByNames';
|
||||
export * from './reqCheckCodes';
|
||||
export * from './reqCreateCard';
|
||||
export * from './reqCreateCardCondition';
|
||||
@ -160,6 +162,7 @@ export * from './reqCreateQuotationSupplierType';
|
||||
export * from './reqCreateQuotationVersionId';
|
||||
export * from './reqCreateSupplier';
|
||||
export * from './reqCreateSupplierCode';
|
||||
export * from './reqCreateSupplierItem';
|
||||
export * from './reqCreateSupplierManagerContactNumber';
|
||||
export * from './reqCreateSupplierManagerEmail';
|
||||
export * from './reqCreateSupplierManagerName';
|
||||
@ -217,6 +220,9 @@ export * from './reqUpdateSupplierManagerEmail';
|
||||
export * from './reqUpdateSupplierManagerName';
|
||||
export * from './reqUpdateSupplierName';
|
||||
export * from './reqUpdateSupplierTotalRevenue';
|
||||
export * from './reqUpdateSupplyType';
|
||||
export * from './resBulkMapByNames';
|
||||
export * from './resBulkMapByNamesMsg';
|
||||
export * from './resCard';
|
||||
export * from './resCardCard';
|
||||
export * from './resCardList';
|
||||
@ -258,6 +264,8 @@ export * from './resItemItem';
|
||||
export * from './resItemList';
|
||||
export * from './resItemListMsg';
|
||||
export * from './resItemMsg';
|
||||
export * from './resItemSupplyTypeList';
|
||||
export * from './resItemSupplyTypeListMsg';
|
||||
export * from './resLastSupplierType';
|
||||
export * from './resLastSupplierTypeMsg';
|
||||
export * from './resLastSupplierTypeQtNumber';
|
||||
@ -312,6 +320,11 @@ export * from './resSessionChat';
|
||||
export * from './resSessionChatMsg';
|
||||
export * from './resSessionChatSessionId';
|
||||
export * from './resSupplier';
|
||||
export * from './resSupplierItem';
|
||||
export * from './resSupplierItemList';
|
||||
export * from './resSupplierItemListMsg';
|
||||
export * from './resSupplierItemMsg';
|
||||
export * from './resSupplierItemSupplierItem';
|
||||
export * from './resSupplierList';
|
||||
export * from './resSupplierListMsg';
|
||||
export * from './resSupplierMsg';
|
||||
@ -324,6 +337,8 @@ export * from './resTargetBreakdownMdPrice';
|
||||
export * from './resTargetBreakdownMsg';
|
||||
export * from './resTargetBreakdownPurchase';
|
||||
export * from './resTargetBreakdownSelling';
|
||||
export * from './resWebPacketProtocol';
|
||||
export * from './resWebPacketProtocolMsg';
|
||||
export * from './sessionData';
|
||||
export * from './sessionDataAnchoringPrice';
|
||||
export * from './sessionDataBidAt';
|
||||
@ -341,6 +356,10 @@ export * from './supplierDataManagerEmail';
|
||||
export * from './supplierDataManagerName';
|
||||
export * from './supplierDataTotalRevenue';
|
||||
export * from './supplierDataUpdatedAt';
|
||||
export * from './supplierItemData';
|
||||
export * from './supplierItemDataCreatedAt';
|
||||
export * from './supplierItemDataItemCode';
|
||||
export * from './supplierItemDataUpdatedAt';
|
||||
export * from './supplierType';
|
||||
export * from './targetCandidate';
|
||||
export * from './userRole';
|
||||
|
||||
11
negodata/front/src/api/generated/model/itemSupplyType.ts
Normal file
11
negodata/front/src/api/generated/model/itemSupplyType.ts
Normal 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;
|
||||
}
|
||||
10
negodata/front/src/api/generated/model/reqBulkMapByNames.ts
Normal file
10
negodata/front/src/api/generated/model/reqBulkMapByNames.ts
Normal 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[];
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
16
negodata/front/src/api/generated/model/resBulkMapByNames.ts
Normal file
16
negodata/front/src/api/generated/model/resBulkMapByNames.ts
Normal 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[];
|
||||
}
|
||||
@ -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;
|
||||
@ -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[];
|
||||
}
|
||||
@ -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;
|
||||
15
negodata/front/src/api/generated/model/resSupplierItem.ts
Normal file
15
negodata/front/src/api/generated/model/resSupplierItem.ts
Normal 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;
|
||||
}
|
||||
@ -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[];
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
19
negodata/front/src/api/generated/model/supplierItemData.ts
Normal file
19
negodata/front/src/api/generated/model/supplierItemData.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
483
negodata/front/src/api/generated/supplier-item/supplier-item.ts
Normal file
483
negodata/front/src/api/generated/supplier-item/supplier-item.ts
Normal 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);
|
||||
}
|
||||
|
||||
178
negodata/front/src/components/ui/combobox.tsx
Normal file
178
negodata/front/src/components/ui/combobox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -18,17 +18,26 @@ type RawRow = {
|
||||
managerName: string;
|
||||
managerEmail: string;
|
||||
totalRevenue: string;
|
||||
products: string; // 취급상품 — 상품명 콤마(,) 나열. 업로드 시 매핑테이블로 들어간다.
|
||||
};
|
||||
|
||||
type ValidatedRow = RawRow & { status: '정상' | '오류'; message: string };
|
||||
|
||||
// 업로드 양식 한 줄(예시 행)
|
||||
type TemplateRow = { name: string; code: string; managerName: string; managerEmail: string; totalRevenue: string };
|
||||
type TemplateRow = { name: string; code: string; managerName: string; managerEmail: string; totalRevenue: string; products: string };
|
||||
|
||||
// 협력사 1건 + 그 협력사에 매핑할 취급상품명 리스트.
|
||||
export type PartnerUploadRow = { supplier: SupplierCreate; products: string[] };
|
||||
// 일괄 등록 결과 — 협력사 등록 실패(행별) + 이름 미매칭으로 건너뛴 취급상품명들.
|
||||
export type PartnerBulkResult = { failures: BulkFailure[]; unmatchedProducts: string[] };
|
||||
|
||||
// "상품A, 상품B" → ['상품A','상품B'] (공백/빈값 제거).
|
||||
const splitNames = (s: string): string[] => s.split(',').map((t) => t.trim()).filter(Boolean);
|
||||
|
||||
type ExcelUploadModalProps = {
|
||||
open: boolean;
|
||||
partners: Partner[]; // 코드 중복 검사용
|
||||
onConfirm: (rows: SupplierCreate[]) => Promise<BulkFailure[]>;
|
||||
onConfirm: (rows: PartnerUploadRow[]) => Promise<PartnerBulkResult>;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
@ -82,8 +91,9 @@ export function downloadPartnerTemplate() {
|
||||
{ header: '담당자명', value: (r) => r.managerName },
|
||||
{ header: '담당자이메일', value: (r) => r.managerEmail },
|
||||
{ header: '총매출액', value: (r) => r.totalRevenue },
|
||||
{ header: '취급상품', value: (r) => r.products },
|
||||
],
|
||||
[{ name: '예시) (주)한빛정밀', code: 'PART-EXAMPLE-001', managerName: '김철수 과장', managerEmail: 'cs.kim@example.com', totalRevenue: '5000000000' }],
|
||||
[{ name: '예시) (주)한빛정밀', code: 'PART-EXAMPLE-001', managerName: '김철수 과장', managerEmail: 'cs.kim@example.com', totalRevenue: '5000000000', products: '고압 에어 컴프레서, 스테인리스 볼밸브' }],
|
||||
);
|
||||
}
|
||||
|
||||
@ -124,6 +134,7 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp
|
||||
managerName: r['담당자명'] ?? '',
|
||||
managerEmail: r['담당자이메일'] ?? '',
|
||||
totalRevenue: r['총매출액'] ?? '',
|
||||
products: r['취급상품'] ?? '',
|
||||
}));
|
||||
setExcelFile(file.name);
|
||||
setRows(loaded);
|
||||
@ -147,7 +158,7 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp
|
||||
};
|
||||
|
||||
// 인라인 편집 — 원본 필드만 갱신(재검증은 파생이 처리)
|
||||
const handleUpdateField = (id: string, field: 'name' | 'code' | 'managerName' | 'managerEmail', value: string) => {
|
||||
const handleUpdateField = (id: string, field: 'name' | 'code' | 'managerName' | 'managerEmail' | 'products', value: string) => {
|
||||
setRows((cur) => cur.map((row) => (row.id === id ? { ...row, [field]: value } : row)));
|
||||
};
|
||||
|
||||
@ -162,10 +173,16 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const failures = await onConfirm(validRows.map(toSupplierCreate));
|
||||
const { failures, unmatchedProducts } = await onConfirm(
|
||||
validRows.map((r) => ({ supplier: toSupplierCreate(r), products: splitNames(r.products) })),
|
||||
);
|
||||
const okCount = validRows.length - failures.length;
|
||||
// 이름이 회사 상품목록에 없어 매핑 못한 취급상품 — 스킵하고 결과에 부기(결정: 미매칭은 스킵+리포트).
|
||||
const unmatchedNote = unmatchedProducts.length
|
||||
? ` · 미매칭 취급상품 ${unmatchedProducts.length}건 건너뜀(${unmatchedProducts.slice(0, 5).join(', ')}${unmatchedProducts.length > 5 ? '…' : ''})`
|
||||
: '';
|
||||
if (failures.length === 0) {
|
||||
showToast(`총 ${okCount}개 협력사가 서버에 일괄 등록되었습니다.`, 'success');
|
||||
showToast(`총 ${okCount}개 협력사가 서버에 일괄 등록되었습니다.${unmatchedNote}`, unmatchedProducts.length ? 'info' : 'success');
|
||||
close();
|
||||
return;
|
||||
}
|
||||
@ -175,7 +192,7 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp
|
||||
const okCodes = new Set(validRows.map((r) => r.code).filter((c) => failMap[c] === undefined));
|
||||
setServerErrors(failMap);
|
||||
setRows((cur) => cur.filter((r) => !okCodes.has(r.code)));
|
||||
showToast(`${okCount}건 등록 완료 · ${failures.length}건 서버 검증 실패(중복코드 등)`, 'error');
|
||||
showToast(`${okCount}건 등록 완료 · ${failures.length}건 서버 검증 실패(중복코드 등)${unmatchedNote}`, 'error');
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : '엑셀 일괄 등록 실패', 'error');
|
||||
}
|
||||
@ -271,6 +288,7 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp
|
||||
<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>
|
||||
</TableHeader>
|
||||
<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)}
|
||||
/>
|
||||
</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>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@ -9,6 +9,7 @@ import { Typography } from '@/components/ui/typography';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Sheet } from '@/components/ui/sheet';
|
||||
import { SupplierItemsManager } from './SupplierItemsManager';
|
||||
import { type Partner } from '../types';
|
||||
|
||||
const schema = z.object({
|
||||
@ -189,6 +190,9 @@ export function PartnerFormSheet({
|
||||
{errors.managerPhone && <p className="text-[10px] text-rose-500">{errors.managerPhone.message}</p>}
|
||||
</div>
|
||||
|
||||
{/* 취급상품 관리 — 수정 모드(협력사 확정)에서만. 추가/삭제/유형변경은 즉시 서버 반영. */}
|
||||
{mode === 'edit' && partner && <SupplierItemsManager supplierId={partner.supplier_id} />}
|
||||
|
||||
{/* Buttons wrapper */}
|
||||
<div className="pt-4 flex items-center gap-2 border-t border-border mt-8 justify-between">
|
||||
{mode === 'edit' && partner && (
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -5,12 +5,14 @@ import {
|
||||
updateSupplier,
|
||||
deleteSupplier,
|
||||
} from '@/api/generated/supplier/supplier';
|
||||
import { bulkMapSupplierItems } from '@/api/generated/supplier-item/supplier-item';
|
||||
import type { ListSuppliersParams } from '@/api/generated/model/listSuppliersParams';
|
||||
import type { ReqCreateSupplier } from '@/api/generated/model/reqCreateSupplier';
|
||||
import type { ReqUpdateSupplier } from '@/api/generated/model/reqUpdateSupplier';
|
||||
import type { ResSupplier } from '@/api/generated/model/resSupplier';
|
||||
import type { SupplierData } from '@/api/generated/model/supplierData';
|
||||
import type { BulkFailure } from '@/lib/excel';
|
||||
import type { PartnerUploadRow, PartnerBulkResult } from '../components/ExcelUploadModal';
|
||||
import type { Partner } from '../types';
|
||||
|
||||
// 엑셀 중복검사 모달이 참조하는 "전체 협력사"용 메타 쿼리(최대 100건).
|
||||
@ -54,18 +56,33 @@ export function usePartners(params: ListSuppliersParams) {
|
||||
};
|
||||
// 엑셀 일괄 등록 — 행별로 순차 생성하되 실패해도 멈추지 않고 사유를 모은다.
|
||||
// 서버 DB 검증(중복코드 등)에 걸린 행은 BulkFailure 로 반환 → 모달이 해당 행만 사유와 함께 남긴다.
|
||||
const bulkCreate = async (rows: ReqCreateSupplier[]): Promise<BulkFailure[]> => {
|
||||
// 등록 성공 시 취급상품(상품명 리스트)을 매핑테이블로 밀어넣는다. 이름 미매칭분은 서버가 스킵하고 돌려줘 리포트한다.
|
||||
const bulkCreate = async (rows: PartnerUploadRow[]): Promise<PartnerBulkResult> => {
|
||||
const failures: BulkFailure[] = [];
|
||||
for (const row of rows) {
|
||||
const unmatched = new Set<string>();
|
||||
for (const { supplier, products } of rows) {
|
||||
try {
|
||||
const msg = supplierError(await createSupplier(row));
|
||||
if (msg) failures.push({ code: row.code ?? '', message: msg });
|
||||
const res = await createSupplier(supplier);
|
||||
const msg = supplierError(res);
|
||||
if (msg) {
|
||||
failures.push({ code: supplier.code ?? '', message: msg });
|
||||
continue;
|
||||
}
|
||||
const newId = res.supplier?.supplier_id;
|
||||
if (newId && products.length) {
|
||||
try {
|
||||
const mapRes = await bulkMapSupplierItems(newId, { names: products });
|
||||
(mapRes.unmatched ?? []).forEach((n) => unmatched.add(n));
|
||||
} catch {
|
||||
// 취급상품 매핑 실패는 협력사 등록 자체를 되돌리지 않는다(등록은 성공 처리).
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
failures.push({ code: row.code ?? '', message: err instanceof Error ? err.message : '등록 실패' });
|
||||
failures.push({ code: supplier.code ?? '', message: err instanceof Error ? err.message : '등록 실패' });
|
||||
}
|
||||
}
|
||||
await refresh();
|
||||
return failures;
|
||||
return { failures, unmatchedProducts: [...unmatched] };
|
||||
};
|
||||
|
||||
// 테이블(현재 페이지) 협력사 + 서버 전체 건수.
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
@ -1,12 +1,18 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { X, PlusSquare, ArrowRight, Loader2, Gavel } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useGetSupplierLastType } from '@/api/generated/quotation/quotation';
|
||||
import { useListItemSupplyTypes } from '@/api/generated/supplier-item/supplier-item';
|
||||
import { useListItems, useGetItem } from '@/api/generated/item/item';
|
||||
import { useListSuppliers } from '@/api/generated/supplier/supplier';
|
||||
import { useListCards } from '@/api/generated/card/card';
|
||||
import { mapCardData } from '@/features/cards/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Typography, typographyVariants } from '@/components/ui/typography';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Combobox, type ComboOption } from '@/components/ui/combobox';
|
||||
import type { Product, Partner, QuotationSetting, NegotiationCard } from '../types';
|
||||
import type { CreateQuotationInput } from '../hooks/useQuotations';
|
||||
import { QuotationType } from '@/api/generated/model';
|
||||
@ -88,11 +94,71 @@ export function QuotationCreateModal({
|
||||
}, [renegoSupplierId, prevSupplierType]);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
// ── 픽리스트 서버검색(상품/협력사/카드) — size 캡 없이 검색으로 도달. 미검색이면 부모가 넘긴 목록으로 기본 노출.
|
||||
const [productQ, setProductQ] = useState('');
|
||||
const [productLabel, setProductLabel] = useState('');
|
||||
const [supplierQ, setSupplierQ] = useState('');
|
||||
const [cardQ, setCardQ] = useState('');
|
||||
const productSearch = useListItems({ search: productQ || undefined, size: 30 });
|
||||
const supplierSearch = useListSuppliers({ search: supplierQ || undefined, size: 30 });
|
||||
const cardSearch = useListCards({ search: cardQ || undefined, size: 30 });
|
||||
|
||||
// 선택 상품은 검색으로 목록이 좁혀져도 파생값(목표가 후보)이 안 깨지게 id로 단건 조회한다.
|
||||
const selItem = useGetItem(productId, { query: { enabled: !!productId } }).data?.item ?? null;
|
||||
// 인터넷최저가·매입가·판매가는 상품 속성 — 모달에선 읽기전용으로만 보여주고, 수정은 상품 상세에서 한다.
|
||||
const selectedProduct = products.find((p) => p.id === productId);
|
||||
const internetLowest = selectedProduct?.internet_lowest_price ?? null;
|
||||
const purchase = selectedProduct?.purchase_price ?? null;
|
||||
const selling = selectedProduct?.selling_price ?? null;
|
||||
const internetLowest = selItem?.internet_lowest_price ?? null;
|
||||
const purchase = selItem?.purchase_price ?? null;
|
||||
const selling = selItem?.selling_price ?? null;
|
||||
|
||||
// 선택 상품의 협력사별 공급유형(제조/유통/총판/없음) — 협력사 리스트에 배지로 덧붙인다(리스트 자체는 재조회 안 함).
|
||||
const supplyTypeQuery = useListItemSupplyTypes(productId, { query: { enabled: !!productId } });
|
||||
const supplyTypeBySupplier = useMemo(() => {
|
||||
const m = new Map<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가가 유일한 후보 → 필수가 된다.
|
||||
const mdNum = Number(mdPrice) || 0;
|
||||
const hasItemCandidate = internetLowest != null || (isReType && (purchase != null || selling != null));
|
||||
@ -247,25 +313,18 @@ export function QuotationCreateModal({
|
||||
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="label">상품</Typography>
|
||||
<Select value={productId} onValueChange={(v) => setProductId(v ?? '')}>
|
||||
<SelectTrigger id="wizard-product" className="w-full">
|
||||
<SelectValue>
|
||||
{(value) => {
|
||||
const p = products.find((pp) => pp.id === value);
|
||||
return p
|
||||
? `${p.name} [${p.code}] (기준가: ₩${(p.price ?? 0).toLocaleString()})`
|
||||
: '협상 대상 상품을 고르세요...';
|
||||
}}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<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>
|
||||
<Combobox
|
||||
id="wizard-product"
|
||||
options={productOptions}
|
||||
loading={productSearch.isLoading}
|
||||
onQueryChange={setProductQ}
|
||||
value={productId || undefined}
|
||||
selectedLabel={productLabel}
|
||||
onSelect={(opt) => { setProductId(opt.id); setProductLabel(opt.label); }}
|
||||
placeholder="협상 대상 상품을 고르세요..."
|
||||
searchPlaceholder="상품명·코드로 검색..."
|
||||
emptyText="일치하는 상품이 없습니다"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* MD 제시가 — 입력 시 목표가로 사용. 상품에 다른 후보가 없으면 유일 후보라 필수. */}
|
||||
@ -328,30 +387,19 @@ export function QuotationCreateModal({
|
||||
{step === 2 && (
|
||||
<div className="space-y-3">
|
||||
<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">
|
||||
{partners.map((part) => {
|
||||
const isChecked = selectedPartnerIds.includes(part.id ?? '');
|
||||
return (
|
||||
<label
|
||||
key={part.id}
|
||||
className="flex items-center justify-between p-3 hover:bg-muted/30 cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
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>
|
||||
{/* 서버검색 다중선택 — 각 행에 선택 상품 취급유형 배지(미매핑=미취급). oneToOne이면 togglePartner가 단일로 강제. */}
|
||||
<Combobox
|
||||
variant="inline"
|
||||
multiple
|
||||
values={selectedPartnerIds}
|
||||
options={supplierOptions}
|
||||
loading={supplierSearch.isLoading}
|
||||
onQueryChange={setSupplierQ}
|
||||
onToggle={(opt) => togglePartner(opt.id)}
|
||||
searchPlaceholder="협력사명·코드·담당자 검색..."
|
||||
emptyText="협력사가 없습니다"
|
||||
maxListHeight="max-h-56"
|
||||
/>
|
||||
|
||||
{/* 협력사 유형 — 항상 노출(처음부터 입력 가능). 재협상(1:1)이면 선택 협력사의 직전 견적 값으로 자동 디폴트. */}
|
||||
<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">
|
||||
1:1 협상에서 AI 협상봇이 발동할 카드입니다.
|
||||
</Typography>
|
||||
<div className="grid grid-cols-2 gap-2 max-h-72 overflow-y-auto">
|
||||
{cards.filter((c) => !c.isWildcard || c.status === 'ACTIVE').map((card) => {
|
||||
const isChecked = selectedCardIds.includes(card.id);
|
||||
return (
|
||||
<div
|
||||
key={card.id}
|
||||
onClick={() => toggleCard(card.id)}
|
||||
className={`p-2.5 rounded border cursor-pointer transition-all flex items-start gap-2 ${
|
||||
isChecked ? 'bg-primary/5 border-primary font-bold' : 'bg-background border-border hover:bg-muted/10'
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
<Combobox
|
||||
variant="inline"
|
||||
multiple
|
||||
values={selectedCardIds}
|
||||
options={cardOptions}
|
||||
loading={cardSearch.isLoading}
|
||||
onQueryChange={setCardQ}
|
||||
onToggle={(opt) => toggleCard(opt.id)}
|
||||
searchPlaceholder="카드명·번호·스크립트 검색..."
|
||||
emptyText="협상카드가 없습니다"
|
||||
maxListHeight="max-h-72"
|
||||
/>
|
||||
</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({
|
||||
options,
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
-- └── negosium_db
|
||||
-- ├── company : companies, users, user_tokens, notifications
|
||||
-- ├── supplier : supplier_users, supplier_user_tokens
|
||||
-- ├── partner : suppliers, items, item_internet_lowest_prices
|
||||
-- ├── partner : suppliers, items, item_internet_lowest_prices, supplier_items
|
||||
-- ├── card : versions, nego_cards, wild_cards, version_nego_cards, version_wild_cards
|
||||
-- ├── quotation : quotation_settings, quotations
|
||||
-- ├── negotiation : sessions, chats, results
|
||||
@ -185,6 +185,16 @@ CREATE TABLE IF NOT EXISTS partner.item_internet_lowest_prices (
|
||||
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS partner.supplier_items (
|
||||
supplier_item_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 매핑 식별자(PK)
|
||||
supplier_id uuid NOT NULL, -- 협력사(partner.suppliers.supplier_id)
|
||||
item_id uuid NOT NULL, -- 상품(partner.items.item_id)
|
||||
supply_type SMALLINT NOT NULL DEFAULT 0, -- 공급 유형(SupplierType): 0=none(없음), 1=distribution(유통), 2=manufacture(제조), 3=sole_agency(총판)
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
|
||||
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- card : 협상 전략 (버전 / 협상카드 / 와일드카드 / 매핑)
|
||||
-- ============================================================
|
||||
@ -379,6 +389,8 @@ CREATE INDEX IF NOT EXISTS idx_suppliers_user_id ON partner.suppliers
|
||||
CREATE INDEX IF NOT EXISTS idx_items_company_id ON partner.items (company_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_items_user_id ON partner.items (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_iilp_item_id ON partner.item_internet_lowest_prices (item_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_items_supplier_id ON partner.supplier_items (supplier_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_items_item_id ON partner.supplier_items (item_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_versions_user_id ON card.versions (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_nego_cards_user_id ON card.nego_cards (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_wild_cards_user_id ON card.wild_cards (user_id);
|
||||
@ -401,6 +413,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_supplier_users_id ON supplier.supplier_
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_companies_biz_number ON company.companies (business_number) WHERE deleted = FALSE AND business_number IS NOT NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_quotations_number ON quotation.quotations (number, round) WHERE deleted = FALSE;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_chats_session_seq ON negotiation.chats (session_id, seq) WHERE deleted = FALSE; -- 세션 내 메시지 순번 유니크 (session 1:N, session_id 조회도 이 인덱스로 커버)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_supplier_items ON partner.supplier_items (supplier_id, item_id) WHERE deleted = FALSE; -- (협력사,상품) 매핑 중복 방지(소프트 삭제분은 재등록 허용)
|
||||
|
||||
|
||||
-- ============================================================
|
||||
|
||||
Loading…
Reference in New Issue
Block a user