# Conflicts:
#	postgres-init/03-seed-negodata.sql
This commit is contained in:
hbyang 2026-06-18 17:53:03 +09:00
commit 088c57f415
26 changed files with 675 additions and 315 deletions

View File

@ -138,6 +138,31 @@ class wild_cards(MainTableMixin, MAIN_BASE):
memo = Column(String(255), nullable=True) # 자유 메모
class versions(MainTableMixin, MAIN_BASE):
__tablename__ = "versions"
version_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 버전 생성 유저
code = Column(Integer, nullable=False, default=0) # 빠른 조회용
name = Column(String(10), nullable=False) # 버전명
class version_nego_cards(MainTableMixin, MAIN_BASE):
__tablename__ = "version_nego_cards"
vnc_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
version_id = Column(UUID(as_uuid=True), nullable=False, index=True) # versions.version_id
nego_card_id = Column(UUID(as_uuid=True), nullable=False, index=True) # nego_cards.nego_card_id
class version_wild_cards(MainTableMixin, MAIN_BASE):
__tablename__ = "version_wild_cards"
vwc_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
version_id = Column(UUID(as_uuid=True), nullable=False, index=True) # versions.version_id
wild_card_id = Column(UUID(as_uuid=True), nullable=False, index=True) # wild_cards.wild_card_id
class quotation_settings(MainTableMixin, MAIN_BASE):
__tablename__ = "quotation_settings"
__table_args__ = {"schema": "quotation"}

View File

@ -8,6 +8,7 @@ process_count = 1
is_ssl = false
is_test = true
client_url = "http://localhost:3000" # CORS 허용
nego_chat_url = "http://localhost:3300" # 공급사 협상 프론트 base(세션 chat 실행 URL). 미설정 시 기본값 동일.
[LogConfig]
print_console = true

View File

@ -8,6 +8,7 @@ class WebServerConfig(ConfigModel):
is_ssl: bool = False
is_test: bool = False
client_url: str = ""
nego_chat_url: str = "http://localhost:3300"
class LogConfig(ConfigModel):

View File

@ -6,7 +6,10 @@ from sqlalchemy import select, func, and_, update
from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import quotations, sessions, chats, nego_cards, wild_cards
from common.database.model.models import (
quotations, sessions, chats, nego_cards, wild_cards, items, quotation_settings,
version_nego_cards, version_wild_cards,
)
from common.enums import ErrorType
from common.logger import LOG
from common.utils.gtime import GTime
@ -28,6 +31,30 @@ class IQuotationCRUD(ABC):
async def add_quotation(self, cdb: AsyncSession, quotation: quotations) -> ErrorType:
pass
@abstractmethod
async def add_sessions(self, cdb: AsyncSession, session_list: list) -> ErrorType:
pass
@abstractmethod
async def get_item_prices(self, cdb: AsyncSession, item_ids) -> Tuple[ErrorType, dict]:
pass
@abstractmethod
async def get_target_margin(self, cdb: AsyncSession, qt_setting_id) -> Tuple[ErrorType, Optional[float]]:
pass
@abstractmethod
async def add_rows(self, cdb: AsyncSession, obj_list: list) -> ErrorType:
pass
@abstractmethod
async def classify_card_ids(self, cdb: AsyncSession, card_ids) -> Tuple[ErrorType, dict]:
pass
@abstractmethod
async def get_version_cards(self, cdb: AsyncSession, version_id) -> Tuple[ErrorType, list]:
pass
@abstractmethod
async def update_quotation(self, cdb: AsyncSession, qt_id, data: dict) -> ErrorType:
pass
@ -52,6 +79,10 @@ class IQuotationCRUD(ABC):
async def session_counts(self, cdb: AsyncSession, qt_ids) -> Tuple[ErrorType, dict]:
pass
@abstractmethod
async def item_map(self, cdb: AsyncSession, qt_ids) -> Tuple[ErrorType, dict]:
pass
class QuotationCRUD(IQuotationCRUD):
async def search(
@ -110,6 +141,32 @@ class QuotationCRUD(IQuotationCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, {}
async def item_map(self, cdb: AsyncSession, qt_ids) -> Tuple[ErrorType, dict]:
"""견적 id 목록에 대해 대표 상품(세션의 첫 item) {qt_id: (item_id, item_name)} 을 한 번에 가져온다."""
try:
if not qt_ids:
return ErrorType.SUCCESS, {}
query = (
select(sessions.quotation_id, sessions.item_id, items.name)
.join(items, items.item_id == sessions.item_id)
.where(
sessions.quotation_id.in_(qt_ids),
sessions.deleted == False, # noqa: E712
items.deleted == False, # noqa: E712
)
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
if err_type != ErrorType.SUCCESS:
return err_type, {}
result = {}
for qid, iid, iname in rows:
if qid not in result: # 견적당 대표 1개(첫 세션 상품)
result[qid] = (iid, iname)
return ErrorType.SUCCESS, result
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, {}
async def get_by_id(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, quotations]:
try:
query = select(quotations).where(quotations.qt_id == qt_id, quotations.deleted == False).limit(1) # noqa: E712
@ -130,6 +187,117 @@ class QuotationCRUD(IQuotationCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED
async def add_sessions(self, cdb: AsyncSession, session_list: list) -> ErrorType:
"""견적 생성 시 만들어진 협상 세션들을 한 번에 insert. 빈 목록이면 그냥 통과."""
try:
if not session_list:
return ErrorType.SUCCESS
return await DB_SESSION_MNG.insert(cdb, session_list)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED
async def add_rows(self, cdb: AsyncSession, obj_list: list) -> ErrorType:
"""임의 ORM 행 묶음 insert(버전/버전-카드 매핑 등). 빈 목록이면 통과."""
try:
if not obj_list:
return ErrorType.SUCCESS
return await DB_SESSION_MNG.insert(cdb, obj_list)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED
async def classify_card_ids(self, cdb: AsyncSession, card_ids) -> Tuple[ErrorType, dict]:
"""선택 카드 id 를 협상(1)/와일드(2)로 분류. {card_id: card_type}."""
try:
if not card_ids:
return ErrorType.SUCCESS, {}
out = {}
n_err, n_rows = await DB_SESSION_MNG.execute(
cdb, select(nego_cards.nego_card_id).where(nego_cards.nego_card_id.in_(card_ids), nego_cards.deleted == False) # noqa: E712
)
if n_err != ErrorType.SUCCESS:
return n_err, {}
for r in n_rows:
out[r] = 1
w_err, w_rows = await DB_SESSION_MNG.execute(
cdb, select(wild_cards.wild_card_id).where(wild_cards.wild_card_id.in_(card_ids), wild_cards.deleted == False) # noqa: E712
)
if w_err != ErrorType.SUCCESS:
return w_err, {}
for r in w_rows:
out[r] = 2
return ErrorType.SUCCESS, out
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, {}
async def get_version_cards(self, cdb: AsyncSession, version_id) -> Tuple[ErrorType, list]:
"""견적 버전에 묶인 카드. version_nego_cards/version_wild_cards 조인.
반환: [(card_type, card_pk, number, name, script, edit_script, condition, memo), ...]."""
try:
out = []
n_q = (
select(
nego_cards.nego_card_id, nego_cards.number, nego_cards.name,
nego_cards.script, nego_cards.edit_script,
)
.join(version_nego_cards, version_nego_cards.nego_card_id == nego_cards.nego_card_id)
.where(version_nego_cards.version_id == version_id, version_nego_cards.deleted == False, nego_cards.deleted == False) # noqa: E712
)
n_err, n_rows = await DB_SESSION_MNG.execute(cdb, n_q)
if n_err != ErrorType.SUCCESS:
return n_err, []
for pk, number, name, script, edit in n_rows:
out.append((1, pk, number, name, script, edit, None, None))
w_q = (
select(
wild_cards.wild_card_id, wild_cards.number, wild_cards.name,
wild_cards.script, wild_cards.edit_script, wild_cards.condition, wild_cards.memo,
)
.join(version_wild_cards, version_wild_cards.wild_card_id == wild_cards.wild_card_id)
.where(version_wild_cards.version_id == version_id, version_wild_cards.deleted == False, wild_cards.deleted == False) # noqa: E712
)
w_err, w_rows = await DB_SESSION_MNG.execute(cdb, w_q)
if w_err != ErrorType.SUCCESS:
return w_err, []
for pk, number, name, script, edit, condition, memo in w_rows:
out.append((2, pk, number, name, script, edit, condition, memo))
return ErrorType.SUCCESS, out
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, []
async def get_item_prices(self, cdb: AsyncSession, item_ids) -> Tuple[ErrorType, dict]:
"""item_id -> price(원, NULL 가능) 매핑. 세션 목표가 계산 입력."""
try:
if not item_ids:
return ErrorType.SUCCESS, {}
query = select(items.item_id, items.price).where(
items.item_id.in_(item_ids), items.deleted == False # noqa: E712
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
if err_type != ErrorType.SUCCESS:
return err_type, {}
return ErrorType.SUCCESS, {r[0]: r[1] for r in rows}
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, {}
async def get_target_margin(self, cdb: AsyncSession, qt_setting_id) -> Tuple[ErrorType, Optional[float]]:
"""견적 세팅의 목표 마진율. 세션 목표가 = price / (1 + margin)."""
try:
query = select(quotation_settings.target_margin_rate).where(
quotation_settings.qt_setting_id == qt_setting_id
).limit(1)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
if err_type != ErrorType.SUCCESS:
return err_type, None
return ErrorType.SUCCESS, (float(rows[0]) if rows and rows[0] is not None else None)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None
async def update_quotation(self, cdb: AsyncSession, qt_id, data: dict) -> ErrorType:
try:
if not data:

View File

@ -13,18 +13,21 @@ class QuotationProtocol(WebPacketProtocol):
class Req_CreateQuotation(QuotationProtocol):
qt_setting_id: uuid.UUID
version_id: uuid.UUID
version_id: Optional[uuid.UUID] = None # 미지정 시 기본 전략 버전(card.versions 시드)으로 채움
name: str = ""
number: str = ""
type: int = 0
status: int = 0
start_time: datetime
start_time: Optional[datetime] = None # 미지정 시 생성 시각(UTC)
end_time: datetime
round: int = 1
manager_name: Optional[str] = None
manager_email: Optional[str] = None
manager_contact_number: Optional[str] = None
memo: Optional[str] = None
item_ids: list[uuid.UUID] = [] # 협상 대상 상품. item×supplier 조합마다 세션 1개 생성
supplier_ids: list[uuid.UUID] = [] # 협상 초청 공급사
card_ids: list[uuid.UUID] = [] # 선택 협상카드. 버전을 만들어 묶고 quotation.version_id 로 연결
class QuotationData(WebPacketProtocol):
@ -52,6 +55,8 @@ class QuotationData(WebPacketProtocol):
equal_bid_yn: Optional[bool] = None
equal_bid_data: Optional[Any] = None
participation_count: int = 0 # 견적별 참여 협력사 수(세션 distinct supplier). 목록 집계로 채움.
item_id: Optional[uuid.UUID] = None # 대표 상품 id(세션의 첫 item). 목록 조인으로 채움.
item_name: Optional[str] = None # 대표 상품명. 목록 조인으로 채움.
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
@ -68,22 +73,6 @@ class Res_DeleteQuotation(Res_WebPacketProtocol):
pass
class AsyncJob(WebPacketProtocol):
status: str = ""
message: str = ""
class Res_CreateQuotation(Res_WebPacketProtocol):
quotation: Optional[QuotationData] = None
async_job: Optional[AsyncJob] = None
class Res_QuotationStatus(Res_WebPacketProtocol):
qt_id: Optional[uuid.UUID] = None
job_status: int = 0
message: str = ""
class SessionData(WebPacketProtocol):
model_config = ConfigDict(from_attributes=True)
@ -102,6 +91,24 @@ class SessionData(WebPacketProtocol):
reject_reason: Optional[str] = None
reject_price: Optional[int] = None
reject_delivery_type: Optional[int] = None
url: str = "" # 세션 chat 실행 URL(공급사 협상 프론트). DB 미저장 — session_id 로 구성
class AsyncJob(WebPacketProtocol):
status: str = ""
message: str = ""
class Res_CreateQuotation(Res_WebPacketProtocol):
quotation: Optional[QuotationData] = None
sessions: list[SessionData] = [] # 견적 생성과 함께 만들어진 협상 세션(각 url 포함)
async_job: Optional[AsyncJob] = None
class Res_QuotationStatus(Res_WebPacketProtocol):
qt_id: Optional[uuid.UUID] = None
job_status: int = 0
message: str = ""
class Res_QuotationSessions(Res_WebPacketProtocol):

View File

@ -1,11 +1,14 @@
import uuid
from datetime import timezone
from fastapi import Depends
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import quotations, sessions, chats
from common.enums import DBWRType, ErrorType
from common.database.model.models import quotations, sessions, chats, versions, version_nego_cards, version_wild_cards
from common.enums import DBWRType, ErrorType, QuotationStatus, SessionStatus
from common.models.gmodel import PageParams
from common.utils.gtime import GTime
from config.server_configs import web_server_config
from crud.quotation_crud import IQuotationCRUD, QuotationCRUD
from router.v1.quotation.protocol import (
AsyncJob,
@ -32,9 +35,42 @@ class QuotationService:
user_id 는 생성 시 소유자로만 기록한다(조회/변경 시 소유권 필터 없음).
"""
# 기본 전략 버전(card.versions 시드). 견적 생성 시 version_id 미지정이면 이 값으로 채운다.
DEFAULT_VERSION_ID = uuid.UUID("00000000-0000-0000-0000-000000000030")
def __init__(self, quotation_crud: IQuotationCRUD = Depends(QuotationCRUD)):
self.quotation_crud = quotation_crud
@staticmethod
def _session_chat_url(session_id) -> str:
"""세션 chat 실행 URL(공급사 협상 프론트). ChatPage 가 session_id 쿼리로 진입한다."""
base = (web_server_config.nego_chat_url or "").rstrip("/")
return f"{base}/chat?session_id={session_id}"
@staticmethod
def _calc_target_price(price, margin) -> int:
"""세션 목표가(원). 단가 있으면 목표 마진율 적용가, 없으면 0."""
if not price:
return 0
if margin and margin > 0:
return int(int(price) / (1 + margin))
return int(price)
@staticmethod
def _naive_utc(dt):
"""DB 컬럼이 naive(TIMESTAMP WITHOUT TIME ZONE)라, tz-aware 입력(프론트 toISOString 등)은 UTC naive 로 변환."""
if dt is None:
return dt
if getattr(dt, "tzinfo", None) is not None:
return dt.astimezone(timezone.utc).replace(tzinfo=None)
return dt
@staticmethod
def _gen_number() -> str:
"""견적번호 자동 생성(미지정 시). EST-YYYYMM-XXXX."""
now = GTime.UTC()
return f"EST-{now:%Y%m}-{uuid.uuid4().hex[:4].upper()}"
async def _fetch(self, qt_id: uuid.UUID):
"""견적 단건 조회. (ErrorType, quotation|None) 반환. (회사 스코프 없음)"""
err_type, quotation = await DB_SESSION_MNG.execute_lambda(
@ -58,9 +94,11 @@ class QuotationService:
res.result.SetResult(err_type)
return res
# 참여 협력사 수(세션 distinct supplier)를 이 페이지 견적들에 대해 한 방으로 세서 합친다(메인 쿼리 비건드림).
# 참여 협력사 수(세션 distinct supplier)와 대표 상품(세션 item)을 이 페이지 견적들에 대해
# 각각 한 방으로 모아 합친다(메인 쿼리 비건드림).
qt_ids = [r.qt_id for r in rows]
counts = {}
item_map = {}
if qt_ids:
cnt_err, got = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(),
@ -69,8 +107,18 @@ class QuotationService:
)
if cnt_err == ErrorType.SUCCESS:
counts = got
im_err, got_im = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(),
DBWRType.DB_READ.value,
lambda s: self.quotation_crud.item_map(s, qt_ids),
)
if im_err == ErrorType.SUCCESS:
item_map = got_im
for r in rows:
r.participation_count = counts.get(r.qt_id, 0)
item = item_map.get(r.qt_id)
if item:
r.item_id, r.item_name = item
res.quotations = [QuotationData.model_validate(r) for r in rows]
res.total = total
@ -87,23 +135,133 @@ class QuotationService:
async def create_quotation(self, user_id: str, data: dict) -> Res_CreateQuotation:
res = Res_CreateQuotation()
quotation = quotations(**data, user_id=uuid.UUID(user_id))
# 세션 생성용 입력은 quotations 컬럼이 아니므로 분리한다(상품 × 공급사 조합마다 세션 1개).
item_ids = data.pop("item_ids", []) or []
supplier_ids = data.pop("supplier_ids", []) or []
card_ids = data.pop("card_ids", []) or []
# NOT NULL 컬럼 보정(프론트 미전송 시 서버 디폴트).
if not data.get("version_id"):
data["version_id"] = self.DEFAULT_VERSION_ID
if not data.get("start_time"):
data["start_time"] = GTime.UTC()
if not data.get("number"):
data["number"] = self._gen_number()
if not data.get("status"):
data["status"] = QuotationStatus.CREATED.value
if not data.get("round"):
data["round"] = 1 # ORM 기본값은 flush 시점이라, 세션 스냅샷용으로 미리 확정한다
# DB 컬럼이 naive 라, tz-aware 로 들어온 시각(프론트 toISOString)을 UTC naive 로 맞춘다.
data["start_time"] = self._naive_utc(data.get("start_time"))
data["end_time"] = self._naive_utc(data.get("end_time"))
# 세션 목표가 입력(상품 단가 + 견적 세팅 목표 마진율). 읽기 트랜잭션에서 먼저 조회.
prices = {}
if item_ids:
_err, prices = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(),
DBWRType.DB_READ.value,
lambda s: self.quotation_crud.get_item_prices(s, item_ids),
)
prices = prices if _err == ErrorType.SUCCESS else {}
_err, margin = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(),
DBWRType.DB_READ.value,
lambda s: self.quotation_crud.get_target_margin(s, data["qt_setting_id"]),
)
margin = margin if _err == ErrorType.SUCCESS else None
# 선택 협상카드가 있으면 새 버전을 만들어 카드들을 묶고, quotation.version_id 로 연결한다.
# (quotation↔card 는 chats 가 아니라 version → version_nego_cards/version_wild_cards 로 연결.)
version_obj = None
link_rows = []
if card_ids:
_err, card_types = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(),
DBWRType.DB_READ.value,
lambda s: self.quotation_crud.classify_card_ids(s, card_ids),
)
card_types = card_types if _err == ErrorType.SUCCESS else {}
new_version_id = uuid.uuid4()
version_obj = versions(
version_id=new_version_id,
user_id=uuid.UUID(user_id),
code=0,
name=(data.get("number") or "견적버전")[:10],
)
for cid in card_ids:
t = card_types.get(cid)
if t == 1:
link_rows.append(version_nego_cards(version_id=new_version_id, nego_card_id=cid))
elif t == 2:
link_rows.append(version_wild_cards(version_id=new_version_id, wild_card_id=cid))
data["version_id"] = new_version_id
# qt_id 를 미리 발급해 세션 FK(quotation_id)와 묶고, 한 트랜잭션에 함께 insert 한다.
qt_id = uuid.uuid4()
quotation = quotations(**data, qt_id=qt_id, user_id=uuid.UUID(user_id))
session_objs = []
for iid in item_ids:
tp = self._calc_target_price(prices.get(iid), margin)
for sid in supplier_ids:
session_objs.append(
sessions(
session_id=uuid.uuid4(),
quotation_id=qt_id,
item_id=iid,
supplier_id=sid,
qt_number=quotation.number,
qt_round=quotation.round,
qt_type=quotation.type,
target_price=tp,
status=SessionStatus.CREATED.value,
end_time=quotation.end_time,
)
)
# 버전 → (버전-카드 매핑) → 견적 → 세션 순으로 한 트랜잭션에 insert(FK 순서 보장).
ops = []
if version_obj is not None:
ops.append(lambda s: self.quotation_crud.add_rows(s, [version_obj]))
ops.append(lambda s: self.quotation_crud.add_rows(s, link_rows))
ops.append(lambda s: self.quotation_crud.add_quotation(s, quotation))
ops.append(lambda s: self.quotation_crud.add_sessions(s, session_objs))
err_type = await DB_SESSION_MNG.execute_lambda_run(
[quotations.DBType()],
[lambda s: self.quotation_crud.add_quotation(s, quotation)],
ops,
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
# 서버 기본값(created_at/updated_at) 로드 위해 재조회 (응답 shape 은 Res_CreateQuotation 유지).
# 서버 기본값(created_at/updated_at) 로드 위해 재조회.
f_err, fresh = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(),
DBWRType.DB_READ.value,
lambda s: self.quotation_crud.get_by_id(s, quotation.qt_id),
lambda s: self.quotation_crud.get_by_id(s, qt_id),
)
res.quotation = QuotationData.model_validate(fresh if f_err == ErrorType.SUCCESS and fresh is not None else quotation)
# 네고시움 백엔드 비동기 요청은 스텁이므로 row 만 생성한다.
res.async_job = AsyncJob(status="submitted", message="견적 생성 작업 요청됨(스텁)")
# 생성된 세션 + 각 세션 chat 실행 URL 을 함께 반환(협상리스트에서 바로 진입 가능).
res.sessions = [
SessionData(
session_id=so.session_id,
qt_id=so.quotation_id,
supplier_id=so.supplier_id,
item_id=so.item_id,
qt_number=so.qt_number,
qt_round=so.qt_round,
qt_type=so.qt_type,
target_price=so.target_price,
status=so.status,
end_time=so.end_time,
url=self._session_chat_url(so.session_id),
)
for so in session_objs
]
res.async_job = AsyncJob(status="created", message=f"협상 세션 {len(session_objs)}건 생성")
return res
async def stop_quotation(self, qt_id: str) -> Res_Quotation:
@ -119,7 +277,7 @@ class QuotationService:
# 상태를 '견적마감'으로 변경(실제 DB 업데이트)
err_type = await DB_SESSION_MNG.execute_lambda_run(
[quotations.DBType()],
[lambda s: self.quotation_crud.update_quotation(s, qt_uuid, {"status": "견적마감"})],
[lambda s: self.quotation_crud.update_quotation(s, qt_uuid, {"status": QuotationStatus.CLOSED.value})],
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
@ -207,6 +365,7 @@ class QuotationService:
reject_reason=r.reject_reason,
reject_price=r.reject_price,
reject_delivery_type=r.reject_delivery_type,
url=self._session_chat_url(r.session_id),
)
for r in rows
]
@ -252,34 +411,34 @@ class QuotationService:
res.result.SetResult(err_type)
return res
# 견적의 버전(quotation.version_id)에 묶인 카드를 조회한다(version_nego_cards/version_wild_cards).
err_type, rows = await DB_SESSION_MNG.execute_lambda(
chats.DBType(),
quotations.DBType(),
DBWRType.DB_READ.value,
lambda s: self.quotation_crud.list_used_cards(s, qt_uuid),
lambda s: self.quotation_crud.get_version_cards(s, quotation.version_id),
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
res.qt_id = quotation.qt_id
# rows = [(chat_row, card_id, number, name, script, edit_script, condition, memo), ...].
# nego/wild 구분은 chats.card_type. condition/memo 는 와일드카드에만 존재.
# rows = [(card_type, card_pk, number, name, script, edit_script, condition, memo), ...].
cards = []
for chat_row, nc_id, nc_number, nc_name, nc_script, nc_edit, wc_condition, wc_memo in rows:
is_wild = chat_row.card_type == 2
for card_type, card_pk, number, name, script, edit, condition, memo in rows:
is_wild = card_type == 2
cards.append(
QuotationCardData(
session_card_id=chat_row.chat_id,
session_card_id=card_pk,
qt_id=quotation.qt_id,
nego_card_id=None if is_wild else nc_id,
wild_card_id=nc_id if is_wild else None,
type=chat_row.card_type if chat_row.card_type is not None else 1,
number=nc_number,
name=nc_name,
script=nc_script,
edit_script=nc_edit,
condition=wc_condition if is_wild else None,
memo=wc_memo if is_wild else None,
nego_card_id=None if is_wild else card_pk,
wild_card_id=card_pk if is_wild else None,
type=card_type,
number=number,
name=name,
script=script,
edit_script=edit,
condition=condition,
memo=memo,
)
)
res.cards = cards

View File

@ -66,6 +66,8 @@ export * from './quotationData';
export * from './quotationDataCreatedAt';
export * from './quotationDataEqualBidData';
export * from './quotationDataEqualBidYn';
export * from './quotationDataItemId';
export * from './quotationDataItemName';
export * from './quotationDataManagerContactNumber';
export * from './quotationDataManagerEmail';
export * from './quotationDataManagerName';
@ -108,6 +110,8 @@ export * from './reqCreateQuotationManagerEmail';
export * from './reqCreateQuotationManagerName';
export * from './reqCreateQuotationMemo';
export * from './reqCreateQuotationSetting';
export * from './reqCreateQuotationStartTime';
export * from './reqCreateQuotationVersionId';
export * from './reqCreateSupplier';
export * from './reqCreateSupplierCode';
export * from './reqCreateSupplierManagerContactNumber';

View File

@ -13,6 +13,8 @@ import type { QuotationDataPreferredSpId } from './quotationDataPreferredSpId';
import type { QuotationDataPreferredSpName } from './quotationDataPreferredSpName';
import type { QuotationDataEqualBidYn } from './quotationDataEqualBidYn';
import type { QuotationDataEqualBidData } from './quotationDataEqualBidData';
import type { QuotationDataItemId } from './quotationDataItemId';
import type { QuotationDataItemName } from './quotationDataItemName';
import type { QuotationDataCreatedAt } from './quotationDataCreatedAt';
import type { QuotationDataUpdatedAt } from './quotationDataUpdatedAt';
@ -39,6 +41,8 @@ export interface QuotationData {
equal_bid_yn?: QuotationDataEqualBidYn;
equal_bid_data?: QuotationDataEqualBidData;
participation_count?: number;
item_id?: QuotationDataItemId;
item_name?: QuotationDataItemName;
created_at?: QuotationDataCreatedAt;
updated_at?: QuotationDataUpdatedAt;
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type QuotationDataItemId = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type QuotationDataItemName = string | null;

View File

@ -4,6 +4,8 @@
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { ReqCreateQuotationVersionId } from './reqCreateQuotationVersionId';
import type { ReqCreateQuotationStartTime } from './reqCreateQuotationStartTime';
import type { ReqCreateQuotationManagerName } from './reqCreateQuotationManagerName';
import type { ReqCreateQuotationManagerEmail } from './reqCreateQuotationManagerEmail';
import type { ReqCreateQuotationManagerContactNumber } from './reqCreateQuotationManagerContactNumber';
@ -11,16 +13,19 @@ import type { ReqCreateQuotationMemo } from './reqCreateQuotationMemo';
export interface ReqCreateQuotation {
qt_setting_id: string;
version_id: string;
version_id?: ReqCreateQuotationVersionId;
name?: string;
number?: string;
type?: number;
status?: number;
start_time: string;
start_time?: ReqCreateQuotationStartTime;
end_time: string;
round?: number;
manager_name?: ReqCreateQuotationManagerName;
manager_email?: ReqCreateQuotationManagerEmail;
manager_contact_number?: ReqCreateQuotationManagerContactNumber;
memo?: ReqCreateQuotationMemo;
item_ids?: string[];
supplier_ids?: string[];
card_ids?: string[];
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type ReqCreateQuotationStartTime = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type ReqCreateQuotationVersionId = string | null;

View File

@ -7,11 +7,13 @@
import type { ErrorInfo } from './errorInfo';
import type { ResCreateQuotationMsg } from './resCreateQuotationMsg';
import type { ResCreateQuotationQuotation } from './resCreateQuotationQuotation';
import type { SessionData } from './sessionData';
import type { ResCreateQuotationAsyncJob } from './resCreateQuotationAsyncJob';
export interface ResCreateQuotation {
result?: ErrorInfo;
msg?: ResCreateQuotationMsg;
quotation?: ResCreateQuotationQuotation;
sessions?: SessionData[];
async_job?: ResCreateQuotationAsyncJob;
}

View File

@ -26,4 +26,5 @@ export interface SessionData {
reject_reason?: SessionDataRejectReason;
reject_price?: SessionDataRejectPrice;
reject_delivery_type?: SessionDataRejectDeliveryType;
url?: string;
}

View File

@ -1,18 +1,21 @@
import type { ReactNode } from 'react';
import { DataTable } from '@/components/ui/data-table';
import type { NegotiationCard } from '../types';
type CardTableProps = {
data: NegotiationCard[];
onEdit: (card: NegotiationCard) => void;
footer?: ReactNode;
};
export function CardTable({ data, onEdit }: CardTableProps) {
export function CardTable({ data, onEdit, footer }: CardTableProps) {
return (
<DataTable
data={data}
rowKey={(card) => card.id}
onRowClick={onEdit}
empty="데이터가 없습니다."
footer={footer}
columns={[
{
header: '구분',

View File

@ -1,5 +1,5 @@
import { useState } from 'react';
import { X, PlusSquare, ArrowRight } from 'lucide-react';
import { X, PlusSquare, ArrowRight, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Typography } from '@/components/ui/typography';
import { Input } from '@/components/ui/input';
@ -13,7 +13,7 @@ type CreateQuotationWizardProps = {
partners: Partner[];
cards: NegotiationCard[];
quotationSettings: QuotationSetting[];
onCreate: (input: CreateQuotationInput) => boolean;
onCreate: (input: CreateQuotationInput) => boolean | Promise<boolean>;
onClose: () => void;
};
@ -34,6 +34,7 @@ export function CreateQuotationWizard({
const [dueDate, setDueDate] = useState('2026-06-15T18:00');
const [settingId, setSettingId] = useState(quotationSettings[0]?.qt_setting_id ?? '');
const [selectedCardIds, setSelectedCardIds] = useState<string[]>([]);
const [submitting, setSubmitting] = useState(false);
if (!open) return null;
@ -46,28 +47,44 @@ export function CreateQuotationWizard({
prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id],
);
const handleSubmit = () => {
const ok = onCreate({
title,
type,
productId,
partnerIds: selectedPartnerIds,
dueDate,
settingId,
cardIds: selectedCardIds,
});
if (ok) onClose();
const handleSubmit = async () => {
if (submitting) return;
setSubmitting(true);
try {
// 서버가 견적+세션 생성을 끝내고 응답할 때까지 기다린 뒤에 완료(닫기) 처리한다.
const ok = await onCreate({
title,
type,
productId,
partnerIds: selectedPartnerIds,
dueDate,
settingId,
cardIds: selectedCardIds,
});
if (ok) onClose();
} finally {
setSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-xs">
{submitting && (
<div className="fixed inset-0 z-[60] flex items-center justify-center">
<div className="flex flex-col items-center gap-3 rounded-xl bg-card px-8 py-6 shadow-2xl border border-border">
<Loader2 className="text-primary animate-spin" size={44} strokeWidth={2.5} />
<span className="text-sm font-semibold text-foreground font-mono">협상견적 생성 중…</span>
<span className="text-[11px] text-muted-foreground font-mono">견적 · 협상 세션 등록 중</span>
</div>
</div>
)}
<div className="w-full max-w-xl bg-card border border-border rounded-lg shadow-2xl p-6 overflow-hidden animate-scale-up font-mono">
{/* Header */}
<div className="flex items-center justify-between pb-4 border-b border-border">
<div className="flex items-center gap-2">
<PlusSquare className="text-foreground" size={18} />
<span className="text-sm font-bold text-foreground">신규 견적 발의 (단계 {step}/3)</span>
<span className="text-sm font-bold text-foreground">신규 협상견적 등록 (단계 {step}/3)</span>
</div>
<button onClick={onClose} className="p-1 rounded text-muted-foreground hover:bg-muted cursor-pointer">
<X size={18} />
@ -233,7 +250,16 @@ export function CreateQuotationWizard({
>
<input type="checkbox" checked={isChecked} readOnly className="accent-primary h-3.5 w-3.5 mt-0.5" />
<div>
<span className="text-[10px] text-muted-foreground font-mono block leading-none">{card.code}</span>
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground font-mono block leading-none">{card.code}</span>
<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>
<span className="text-xs text-foreground mt-1 block leading-tight">{card.title}</span>
</div>
</div>
@ -263,8 +289,8 @@ export function CreateQuotationWizard({
다음 단계로
</Button>
) : (
<Button type="button" size="sm" onClick={handleSubmit}>
견적 발의 등록
<Button type="button" size="sm" onClick={handleSubmit} disabled={submitting}>
{submitting ? '생성 중…' : '협상견적 등록'}
</Button>
)}
</div>

View File

@ -8,7 +8,10 @@ import {
Layers,
Sparkles,
Package,
ExternalLink,
Copy,
} from 'lucide-react';
import { showToast } from '@/lib/notify';
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
import { Typography } from '@/components/ui/typography';
import { Input } from '@/components/ui/input';
@ -390,6 +393,7 @@ export function QuotationDetailDrawer({
<TableRow>
<TableHead className="p-3 font-semibold">세션 ID</TableHead>
<TableHead className="p-3 font-semibold font-sans">협력사</TableHead>
<TableHead className="p-3 font-semibold font-sans">협상 URL</TableHead>
<TableHead className="p-3 font-semibold font-sans">상품</TableHead>
<TableHead className="p-3 font-semibold text-center font-sans">협상상태</TableHead>
<TableHead className="p-3 font-semibold text-right">목표가</TableHead>
@ -404,7 +408,7 @@ export function QuotationDetailDrawer({
<TableBody className="divide-y divide-border">
{sessionViews.length === 0 && (
<TableRow>
<TableCell colSpan={11} className="p-12 text-center text-muted-foreground">
<TableCell colSpan={12} className="p-12 text-center text-muted-foreground">
참여 중인 협상 세션이 없습니다. (리스트가 비어 있습니다)
</TableCell>
</TableRow>
@ -424,6 +428,33 @@ export function QuotationDetailDrawer({
</button>
</div>
</TableCell>
<TableCell className="p-3 font-mono">
{sess.url ? (
<div className="flex items-center gap-1.5">
<a
href={sess.url}
target="_blank"
rel="noopener noreferrer"
title={sess.url}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-primary/10 text-primary hover:bg-primary/20 transition-colors text-[10px] font-semibold"
>
<ExternalLink size={11} /> 세션 열기
</a>
<button
onClick={() => {
void navigator.clipboard?.writeText(sess.url);
showToast('협상 URL을 복사했습니다.', 'success');
}}
title="협상 URL 복사"
className="p-1 hover:bg-muted rounded text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
<Copy size={12} />
</button>
</div>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="p-3 font-semibold font-sans">{sess.item_name}</TableCell>
<TableCell className="p-3 text-center">
<span

View File

@ -1,3 +1,4 @@
import type { ReactNode } from 'react';
import { Clock, Building2 } from 'lucide-react';
import { DataTable } from '@/components/ui/data-table';
import { type Estimate, type Product, normalizeQuotationStatus } from '../types';
@ -6,6 +7,7 @@ type QuotationTableProps = {
data: Estimate[];
products: Product[];
onOpenDetail: (id: string) => void;
footer?: ReactNode;
};
const statusBadgeClass = (status?: string | null) => {
@ -23,18 +25,20 @@ const statusBadgeClass = (status?: string | null) => {
}
};
export function QuotationTable({ data, products, onOpenDetail }: QuotationTableProps) {
export function QuotationTable({ data, products, onOpenDetail, footer }: QuotationTableProps) {
return (
<DataTable
data={data}
rowKey={(est) => est.id ?? ''}
onRowClick={(est) => onOpenDetail(est.id ?? '')}
empty="진행 중인 전자 견적 및 자동 협상 계약 내역이 존재하지 않습니다."
footer={footer}
columns={[
{
header: '견적건명',
cell: (est) => {
const product = products.find((p) => p.id === est.productId);
const productName = product?.name ?? est.productName; // 목록에 없으면 서버 조인 상품명으로 폴백
return (
<div>
<span className="font-bold text-sm text-foreground hover:underline cursor-pointer block">
@ -42,7 +46,7 @@ export function QuotationTable({ data, products, onOpenDetail }: QuotationTableP
</span>
<span className="text-[10px] text-muted-foreground font-mono flex items-center gap-1.5 mt-0.5">
<Building2 size={10} />
대상 상품: {product ? product.name : '확인 불가'} (₩{(product?.price ?? 0).toLocaleString()})
대상 상품: {productName ?? '확인 불가'} (₩{(product?.price ?? 0).toLocaleString()})
</span>
</div>
);

View File

@ -10,7 +10,13 @@ import {
useDeleteSetting,
getListSettingsQueryKey,
} from '@/api/generated/quotation-setting/quotation-setting';
import { useListQuotations } from '@/api/generated/quotation/quotation';
import {
useListQuotations,
useCreateQuotation,
useStopQuotation,
getListQuotationsQueryKey,
} from '@/api/generated/quotation/quotation';
import type { ReqCreateQuotation } from '@/api/generated/model/reqCreateQuotation';
import type { ItemData } from '@/api/generated/model/itemData';
import type { SupplierData } from '@/api/generated/model/supplierData';
import type { QuotationSettingData } from '@/api/generated/model/quotationSettingData';
@ -18,7 +24,7 @@ import type { QuotationData } from '@/api/generated/model/quotationData';
import type { CardData } from '@/api/generated/model/cardData';
import { showToast } from '@/lib/notify';
import { confirm } from '@/lib/confirm';
import type { Estimate, ChatSession, Product } from '@/types';
import type { Estimate } from '@/types';
import { unwrap, mapItem, mapSupplier, mapSetting, mapQuotation } from '../types';
export type CreateQuotationInput = {
@ -49,6 +55,11 @@ export function useQuotations() {
const quotationsQuery = useListQuotations(undefined);
const createSettingMutation = useCreateSetting();
const deleteSettingMutation = useDeleteSetting();
const createQuotationMutation = useCreateQuotation();
const stopQuotationMutation = useStopQuotation();
const invalidateQuotations = () =>
queryClient.invalidateQueries({ queryKey: getListQuotationsQueryKey(undefined) });
const products = (unwrap<{ items?: ItemData[] }>(itemsQuery.data)?.items ?? []).map(mapItem);
const partners = (unwrap<{ suppliers?: SupplierData[] }>(suppliersQuery.data)?.suppliers ?? []).map(mapSupplier);
@ -64,15 +75,27 @@ export function useQuotations() {
if (qs) setQuotations(qs.map(mapQuotation));
}, [quotationsQuery.data]);
// 협상카드 카탈로그는 서버(orval)에서 읽어 단계 3/3 카드 선택지로 쓴다. 채팅은 아직 미연동.
// 협상카드 카탈로그는 서버(orval)에서 읽어 단계 3/3 카드 선택지로 쓴다.
const cards = (unwrap<{ cards?: CardData[] }>(cardsQuery.data)?.cards ?? []).map(mapCardData);
const [chatSessions, setChatSessions] = useState<Record<string, ChatSession[]>>({});
// 협상 강제중단 → '협상보류'.
// 협상 강제중단 → 서버 stop_quotation 호출(상태 '견적마감'으로 영속). 성공 시 목록 무효화로 서버값 재동기화.
const stopNegotiation = async (id: string, name: string) => {
if (!(await confirm({ title: '협상 강제중단', description: `현재 입찰 중인 [${name}] 단가 협상 절차를 즉시 조기 중단(강제종료)하시겠습니까?`, confirmText: '중단', destructive: true }))) return;
setQuotations((prev) => prev.map((e) => (e.id === id ? { ...e, status: '협상보류' } : e)));
showToast("해당 협상이 관리자에 의하여 '협상보류' 상태로 지정되었습니다.", 'info');
// 낙관적 갱신 — 서버가 CLOSED 로 바꾸므로 화면도 '견적마감'으로 선반영.
setQuotations((prev) => prev.map((e) => (e.id === id ? { ...e, status: '견적마감' } : e)));
stopQuotationMutation.mutate(
{ qtId: id },
{
onSuccess: () => {
invalidateQuotations();
showToast(`[${name}] 협상이 중단되어 '견적마감' 처리되었습니다.`, 'info');
},
onError: () => {
invalidateQuotations(); // 실패 시 서버 진짜값으로 롤백
showToast('견적 중단에 실패했습니다. 잠시 후 다시 시도해 주세요.', 'error');
},
},
);
};
const invalidateSettings = () =>
@ -120,51 +143,48 @@ export function useQuotations() {
);
};
// 신규 견적 발의 — 견적 레코드 + 협력사별 채팅 세션을 생성. 검증 실패 시 toast 후 false.
const createQuotation = (input: CreateQuotationInput): boolean => {
// 신규 협상견적 등록 — 서버에 견적 + 협력사별 협상 세션(상품×공급사)을 생성한다.
// 서버 응답(실제 qt_id)을 기다린 뒤에야 완료 처리한다. 성공 시 새 qt_id 반환, 실패/검증오류 시 null.
const createQuotation = async (input: CreateQuotationInput): Promise<string | null> => {
if (!input.title.trim()) {
showToast('견적 건명을 올바르게 작성해 주세요.', 'error');
return false;
return null;
}
if (!input.productId) {
showToast('협상 대상 품목을 지정하지 않았습니다.', 'error');
return false;
return null;
}
if (input.partnerIds.length === 0) {
showToast('최소 한 곳 이상의 벤더사(참여 협력사)를 선정해 주세요.', 'error');
return false;
return null;
}
const targetProduct = products.find((p) => p.id === input.productId);
const estId = `est-${Date.now()}`;
const newEstNumber = `EST-202606-${Math.floor(1000 + Math.random() * 9000)}`;
const newQuotationRecord: Estimate = {
id: estId,
title: input.title,
number: newEstNumber,
type: input.type,
round: 1,
status: '견적생성',
dueDate: input.dueDate.replace('T', ' '),
participationCount: input.partnerIds.length,
productId: input.productId,
partnerIds: input.partnerIds,
settingApplied: input.settingId,
usedCardIds: input.cardIds,
winnerPartnerId: undefined,
finalPrice: undefined,
isEqualPrice: false,
const payload: ReqCreateQuotation = {
qt_setting_id: input.settingId,
name: input.title,
type: input.type === 'RE_ESTIMATE' ? 2 : 1,
end_time: new Date(input.dueDate).toISOString(),
item_ids: [input.productId],
supplier_ids: input.partnerIds,
card_ids: input.cardIds,
};
const generatedSessions: ChatSession[] = input.partnerIds.map((pId) =>
buildInitialSession(pId, estId, partners, targetProduct),
);
setChatSessions((prev) => ({ ...prev, [estId]: generatedSessions }));
setQuotations((prev) => [newQuotationRecord, ...prev]);
showToast(`신규 견적 제안서[${input.title}]가 발행되었습니다.`, 'info');
return true;
try {
const res = await createQuotationMutation.mutateAsync({ data: payload });
const qtId = res?.quotation?.qt_id ?? null;
// 서버가 result.success=false 거나 qt_id 가 없으면 실패로 처리(HTTP 200 이어도). 모달은 그대로 유지.
if (!res?.result?.success || !qtId) {
showToast(`견적 생성 실패: ${res?.result?.desc ?? '서버 오류'}`, 'error');
return null;
}
// 목록 재조회가 끝나야 새 견적이 정본 목록에 들어온다(상세 자동오픈이 그 행을 찾을 수 있게).
await invalidateQuotations();
showToast(`협상견적[${input.title}] 생성 완료 — 협상 세션 ${res?.sessions?.length ?? 0}건.`, 'success');
return qtId;
} catch {
showToast('견적 생성에 실패했습니다. 입력값을 확인해 주세요.', 'error');
return null;
}
};
return {
@ -173,62 +193,9 @@ export function useQuotations() {
cards,
quotations,
quotationSettings,
chatSessions,
stopNegotiation,
addSetting,
deleteSetting,
createQuotation,
};
}
// 참여 협력사 1곳에 대한 초기 채팅 세션(시스템/봇/협력사 메시지) 생성.
function buildInitialSession(
pId: string,
estId: string,
partners: ReturnType<typeof mapSupplier>[],
targetProduct: Product | undefined,
): ChatSession {
const partnerObj = partners.find((part) => part.id === pId);
const partnerName = partnerObj?.name ?? '미지정 파트너';
const initialPartnerBid = Math.round((targetProduct?.price || 1000000) * (0.95 + Math.random() * 0.1));
const targetNegoPrice = Math.round((targetProduct?.price || 1000000) * 0.9);
return {
id: pId,
partnerName,
status: 'NEGOTIATING',
currentBid: initialPartnerBid,
bidTime: '2026-06-11 01:10',
messages: [
{
id: `msg-${estId}-sys`,
sender: 'SYSTEM',
timestamp: '2026-06-11 01:00',
content: `사전에 조율된 [${targetProduct?.name}] 입찰 참여 세션이 개시되었습니다.`,
},
{
id: `msg-${estId}-bot`,
sender: 'BOT',
timestamp: '2026-06-11 01:02',
content: '안녕하세요, 구매 대행 봇입니다.',
editorScript: [
{
type: 'paragraph',
children: [
{ text: `${partnerName} 조달담당자님 안녕하십니까.\n` },
{ text: `이번에 진행되는 ${targetProduct?.name} 품목에 대해 당사 타깃가는 ` },
{ text: `${targetNegoPrice.toLocaleString('ko-KR')}원`, bold: true, color: 'primary' },
{ text: ' 수준입니다. 상호 이익 극대화를 위해 적합 단가를 제안해주시면 검토 후 즉시 타결 또는 와일드카드 혜택이 적용됩니다.' },
],
},
],
},
{
id: `msg-${estId}-part`,
sender: 'PARTNER',
timestamp: '2026-06-11 01:05',
content: `안녕하세요. 본 제품은 제조 원가 고사양으로 인해 타사 대비 높은 정밀도가 들어갑니다. 우선 1차 투찰로 ${initialPartnerBid.toLocaleString()}원을 입력 제출합니다.`,
},
],
};
}

View File

@ -69,6 +69,8 @@ export function mapQuotation(q: QuotationData): Estimate {
type: normalizeQuotationType(q.type),
status: normalizeQuotationStatus(q.status) || String(q.status ?? ''),
settingApplied: q.qt_setting_id, // 드로어 견적세팅 카드가 qt_setting_id 로 매칭
productId: q.item_id ?? undefined, // 서버 목록 조인(세션 대표 상품). products 목록과 id 매칭용
productName: q.item_name ?? undefined, // products 목록에 없을 때 표기 폴백
dueDate: formatDueDate(q.end_time),
participationCount: q.participation_count ?? 0,
winnerPartnerId: q.preferred_sp_id ?? q.preferred_sp_name ?? null,
@ -156,6 +158,7 @@ export type SessionView = {
reject_price: number | null;
reject_delivery_type: string | null;
end_time: string;
url: string; // 세션 chat 실행 URL(공급사 협상 프론트)
};
export type QuotationCardView = {
@ -249,6 +252,7 @@ export function buildSessions(
reject_price,
reject_delivery_type,
end_time: est.end_time || est.dueDate || '미지정',
url: '',
};
});
}
@ -289,6 +293,7 @@ export function mapServerSessionView(sd: SessionData, partners: Partner[], produ
? DELIVERY_TYPE_LABEL[sd.reject_delivery_type] || String(sd.reject_delivery_type)
: null,
end_time: fmtDateTime(sd.end_time),
url: sd.url || '',
};
}

View File

@ -0,0 +1,22 @@
import { useEffect, useState } from 'react';
// 클라이언트사이드 페이지네이션 단일 출처.
// 서버가 전량(또는 큰 페이지)을 한 번에 주고, 검색·필터를 프론트에서 거른 뒤
// 그 결과 배열을 화면용으로 잘라 보여줄 때 쓴다(견적·카드 목록).
// 서버 page/size 를 직접 보내는 useServerList 와 달리, 이 훅은 이미 받은 배열을 slice 만 한다.
export function useClientPagination<T>(items: T[], pageSize = 10) {
const [page, setPage] = useState(1);
const totalCount = items.length;
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
// 필터·삭제로 결과가 줄어 현재 페이지가 범위를 벗어나면 마지막 페이지로 당긴다.
useEffect(() => {
if (page > totalPages) setPage(totalPages);
}, [page, totalPages]);
const safePage = Math.min(page, totalPages);
const pageItems = items.slice((safePage - 1) * pageSize, safePage * pageSize);
return { page: safePage, setPage, pageSize, totalPages, totalCount, pageItems };
}

View File

@ -4,7 +4,9 @@ import { showToast } from '@/lib/notify';
import { confirm } from '@/lib/confirm';
import { PageContainer } from '@/components/layout/PageContainer';
import { SearchInput } from '@/components/layout/PageToolbar';
import { TablePagination } from '@/components/ui/table-pagination';
import { Typography } from '@/components/ui/typography';
import { useClientPagination } from '@/lib/useClientPagination';
import { useCards } from '@/features/cards/hooks/useCards';
import { useCardFilters } from '@/features/cards/hooks/useCardFilters';
import { CardTable } from '@/features/cards/components/CardTable';
@ -14,6 +16,7 @@ import type { CardTab, NegotiationCard } from '@/features/cards/types';
export default function CardsPage() {
const { cards, createCard, updateCard, deleteCard } = useCards();
const { search, setSearch, activeTab, setActiveTab, filtered, counts } = useCardFilters(cards);
const { page, setPage, pageSize, totalPages, totalCount, pageItems } = useClientPagination(filtered);
// 오버레이(폼)를 쿼리스트링으로 → 딥링크·뒤로가기·새로고침 지원.
// ?edit=<id> 직접 접근 시 데이터 로드 후 수정 폼이 자동으로 열린다.
@ -93,7 +96,21 @@ export default function CardsPage() {
placeholder="전체 카드이름, 카드번호, 코드 및 핵심멘트 검색..."
/>
<CardTable data={filtered} onEdit={openEdit} />
<CardTable
data={pageItems}
onEdit={openEdit}
footer={
<TablePagination
page={page}
totalPages={totalPages}
totalCount={totalCount}
pageSize={pageSize}
onPageChange={setPage}
label="전체 카드"
unit="개"
/>
}
/>
{isFormOpen && (
<CardFormSheet

View File

@ -2,7 +2,9 @@ import { Settings, Plus } from 'lucide-react';
import { useOverlayParams } from '@/lib/useOverlayParams';
import { PageContainer } from '@/components/layout/PageContainer';
import { PageToolbar, SearchInput } from '@/components/layout/PageToolbar';
import { TablePagination } from '@/components/ui/table-pagination';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useClientPagination } from '@/lib/useClientPagination';
import { useQuotations } from '@/features/quotations/hooks/useQuotations';
import { useQuotationFilters } from '@/features/quotations/hooks/useQuotationFilters';
import { QuotationTable } from '@/features/quotations/components/QuotationTable';
@ -26,6 +28,7 @@ export default function QuotationPage() {
const { search, setSearch, statusFilter, setStatusFilter, typeFilter, setTypeFilter, filtered } =
useQuotationFilters(quotations);
const { page, setPage, pageSize, totalPages, totalCount, pageItems } = useClientPagination(filtered);
// 오버레이(상세/생성/세팅)를 쿼리스트링으로 → 딥링크·뒤로가기·새로고침 지원.
// ?detail=<id> 로 직접 접근하면 데이터 로드 후 상세가 자동으로 열린다.
@ -56,7 +59,7 @@ export default function QuotationPage() {
className="flex items-center gap-2 px-4 py-2.5 bg-primary text-primary-foreground text-xs font-bold rounded hover:opacity-95 cursor-pointer transition-colors animate-pulse"
>
<Plus size={15} />
<span>신규 협상견적 발의</span>
<span>신규 협상견적 등록</span>
</button>
</>
}
@ -97,9 +100,20 @@ export default function QuotationPage() {
</PageToolbar>
<QuotationTable
data={filtered}
data={pageItems}
products={products}
onOpenDetail={(id) => overlay.open('detail', id)}
footer={
<TablePagination
page={page}
totalPages={totalPages}
totalCount={totalCount}
pageSize={pageSize}
onPageChange={setPage}
label="전체 견적"
unit="건"
/>
}
/>
{activeQuotation && (
@ -121,7 +135,11 @@ export default function QuotationPage() {
partners={partners}
cards={cards}
quotationSettings={quotationSettings}
onCreate={createQuotation}
onCreate={async (input) => {
const qtId = await createQuotation(input);
if (qtId) overlay.open('detail', qtId); // 생성 완료된 실제 qt_id 로 상세(협상현황) 자동 오픈
return !!qtId;
}}
onClose={overlay.close}
/>
)}

View File

@ -263,6 +263,7 @@ export type Estimate = Partial<Quotation> & {
dueDate?: string; // end_time
title?: string; // mapped to name in UI
productId?: string; // mapped to association
productName?: string; // 서버 목록 조인 상품명(products 목록에 없을 때 폴백)
partnerIds?: string[]; // mapped B2B suppliers
participationCount?: number;
winnerPartnerId?: string | null;

View File

@ -3,169 +3,26 @@
-- 전체/공용 시드가 아니다. 재실행 안전(WHERE NOT EXISTS) — 운영 DB 에는 적용하지 않는다.
-- admin 계정은 negodata 프론트 로그인 폼 기본값(admin / admin1234)에 대응한다.
-- 적용(도커 postgres): docker exec -i negosium-db psql -U postgres -d negosium_db < postgres-init/03-seed-negodata.sql
-- created_at/updated_at/deleted 은 MainTableMixin server_default 로 자동 채워져 INSERT 에 안 넣는다.
\connect negosium_db
-- 회사 1개 (고정 UUID). status 1=active.
INSERT INTO company.companies (company_id, name, email, status)
SELECT '00000000-0000-0000-0000-000000000001', 'Negosium', 'admin@negosium.dev', 1
-- 회사 1개 (고정 UUID). status 1=active. code/industry 는 코드값이라 비워둠(nullable).
INSERT INTO company.companies (company_id, name, business_number, representative_name, email, contact_number, website_url, status)
SELECT '00000000-0000-0000-0000-000000000001', '아이마켓코리아', '220-88-21724', '홍길동',
'admin@imarketkorea.com', '02-3708-5000', 'https://www.imarketkorea.com', 1
WHERE NOT EXISTS (
SELECT 1 FROM company.companies WHERE company_id = '00000000-0000-0000-0000-000000000001'
);
-- admin 유저. password 는 'admin123' 의 bcrypt 해시(백엔드 GetHashedPW 와 동일 알고리즘, checkpw 로 검증됨).
-- role 2=manager (UserRole.MANAGER; ADMIN 코드는 enum 에 없어 최상위인 MANAGER 사용). status 1=active.
INSERT INTO company.users (user_id, company_id, id, password, name, email, last_accessed_at, status, role)
INSERT INTO company.users (user_id, company_id, id, password, name, email, contact_number, last_accessed_at, status, role)
SELECT '00000000-0000-0000-0000-000000000002',
'00000000-0000-0000-0000-000000000001',
'admin',
'$2b$12$KY4T0kXQ2npvvt71iWZG0.JZHlMNt9angIkE/7.lBC4vta4dHgrj2',
'admin', 'admin@negosium.dev', now(), 1, 2
'관리자', 'admin@imarketkorea.com', '02-3708-5000', now(), 1, 2
WHERE NOT EXISTS (
SELECT 1 FROM company.users WHERE id = 'admin' AND deleted = FALSE
);
-- 데모 상품 5개. company_id/user_id 는 'admin' 유저의 소속에서 가져온다
-- (clean DB=Negosium, 테스트가 남긴 DB=그 회사 — 어느 쪽이든 admin 으로 로그인하면 보이도록).
-- category 는 프론트 categoriesList 와 맞춤. quantity_unit 은 ORM(String) 기준 varchar 라벨.
INSERT INTO partner.items (item_id, company_id, user_id, name, code, price, category, model_name, spec, moq, lead_time, manufacturer, made_in, quantity_unit, vat_yn, delivery_fee_yn, internet_lowest_price_yn, category_type)
SELECT v.item_id, u.company_id, u.user_id, v.name, v.code, v.price, v.category, v.model_name, v.spec, v.moq, v.lead_time, v.manufacturer, v.made_in, v.quantity_unit, v.vat_yn, v.delivery_fee_yn, FALSE, v.category_type
FROM company.users u
CROSS JOIN (VALUES
('00000000-0000-0000-0000-000000000010'::uuid, '리튬인산철 배터리 모듈', 'BAT-LFP-100', 1250000::bigint, '에너지/배터리', 'LFP-100A', '3.2V 100Ah', '10', 14::smallint, '한성에너지', '대한민국', 'EA', TRUE, TRUE, 1),
('00000000-0000-0000-0000-000000000011'::uuid, '산업용 6축 로봇암', 'ROB-6AX-22', 18900000::bigint, '자동화설비', 'RX-6A', '가반하중 12kg', '1', 30::smallint, '오토메카', '일본', 'EA', TRUE, FALSE, 2),
('00000000-0000-0000-0000-000000000012'::uuid, 'MEMS 가속도 센서', 'SEN-MEMS-3A', 8500::bigint, '반도체소자/센서', 'MA-3X', '±16g 3축', '100', 7::smallint, '센서텍', '대만', 'EA', TRUE, TRUE, 3),
('00000000-0000-0000-0000-000000000013'::uuid, '탄소섬유 시트', 'MAT-CF-12', 320000::bigint, '신소재', 'CF-T700', '0.5T 1000x1000', '5', 21::smallint, '카본소재', '대한민국', 'BOX', TRUE, FALSE, 4),
('00000000-0000-0000-0000-000000000014'::uuid, '광트랜시버 SFP+ 10G', 'OPT-SFP-10G', 95000::bigint, '광학/통신', 'SFP-10G-LR', '10km LC', '20', 10::smallint, '옵틱링크', '중국', 'EA', TRUE, TRUE, 5)
) AS v(item_id, name, code, price, category, model_name, spec, moq, lead_time, manufacturer, made_in, quantity_unit, vat_yn, delivery_fee_yn, category_type)
WHERE u.id = 'admin' AND u.deleted = FALSE
AND NOT EXISTS (SELECT 1 FROM partner.items i WHERE i.item_id = v.item_id);
-- 견적 세팅 1개 (admin 소유, 고정 UUID). target_margin_rate 는 비율(0.12 = 12%), anchoring_value 0.01, card_count 3.
-- 견적(quotations)이 qt_setting_id 로 참조하므로 선행 시드.
INSERT INTO quotation.quotation_settings (qt_setting_id, user_id, target_margin_rate, anchoring_value, card_count)
SELECT '00000000-0000-0000-0000-000000000020', u.user_id, 0.12, 0.01, 3
FROM company.users u
WHERE u.id = 'admin' AND u.deleted = FALSE
AND NOT EXISTS (SELECT 1 FROM quotation.quotation_settings qs WHERE qs.qt_setting_id = '00000000-0000-0000-0000-000000000020');
-- 협상전략 버전 1개 (admin 소유, 고정 UUID). quotations.version_id 가 NOT NULL 이라 참조 대상으로 시드.
INSERT INTO card.versions (version_id, user_id, code, name)
SELECT '00000000-0000-0000-0000-000000000030', u.user_id, 1, '기본전략'
FROM company.users u
WHERE u.id = 'admin' AND u.deleted = FALSE
AND NOT EXISTS (SELECT 1 FROM card.versions vv WHERE vv.version_id = '00000000-0000-0000-0000-000000000030');
-- 데모 견적 4건. status 코드 = QuotationStatus(1=견적생성 2=견적진행중 3=견적마감 4=협상보류),
-- type 코드 = QuotationType(1=재협상 2=재견적). 마감기한은 시드 적용 시점 기준 상대시각.
INSERT INTO quotation.quotations (
qt_id, user_id, qt_setting_id, version_id, name, number, type, round, status,
start_time, end_time, manager_name, manager_email, manager_contact_number, memo,
iteration, preferred_sp_yn, preferred_sp_name, equal_bid_yn, equal_bid_data
)
SELECT v.qt_id, u.user_id, '00000000-0000-0000-0000-000000000020', '00000000-0000-0000-0000-000000000030',
v.name, v.number, v.type, v.round, v.status,
v.start_time, v.end_time, v.manager_name, v.manager_email, v.manager_contact_number, v.memo,
v.iteration, v.preferred_sp_yn, v.preferred_sp_name, v.equal_bid_yn, v.equal_bid_data
FROM company.users u
CROSS JOIN (VALUES
('00000000-0000-0000-0000-000000000040'::uuid, '사무용 복합기 대량구매 재견적', 'EST-202606-1001', 2::smallint, 1, 1::smallint,
now(), now() + interval '5 day', '김조달', 'buyer1@negosium.dev', '02-1000-2001', '연간 단가계약 목적 재견적', 0, NULL, NULL, NULL, NULL::jsonb),
('00000000-0000-0000-0000-000000000041'::uuid, '리튬인산철 배터리 단가 재협상', 'EST-202606-1002', 1::smallint, 2, 2::smallint,
now() - interval '2 day', now() + interval '3 day', '이매입', 'buyer2@negosium.dev', '02-1000-2002', '2차 라운드 진행중', 1, NULL, NULL, NULL, NULL::jsonb),
('00000000-0000-0000-0000-000000000042'::uuid, 'MEMS 가속도 센서 연간 단가견적', 'EST-202606-1003', 2::smallint, 1, 3::smallint,
now() - interval '10 day', now() - interval '1 day', '박구매', 'buyer3@negosium.dev', '02-1000-2003', '낙찰 완료, 동일가 발생', 1, TRUE, '센서텍', TRUE, '{"part-a":730000,"part-b":730000}'::jsonb),
('00000000-0000-0000-0000-000000000043'::uuid, '산업용 6축 로봇암 도입 협상', 'EST-202606-1004', 1::smallint, 1, 4::smallint,
now() - interval '4 day', now() + interval '2 day', '최담당', 'buyer4@negosium.dev', '02-1000-2004', '관리자 사유로 협상보류', 0, NULL, NULL, NULL, NULL::jsonb)
) AS v(qt_id, name, number, type, round, status, start_time, end_time, manager_name, manager_email, manager_contact_number, memo, iteration, preferred_sp_yn, preferred_sp_name, equal_bid_yn, equal_bid_data)
WHERE u.id = 'admin' AND u.deleted = FALSE
AND NOT EXISTS (SELECT 1 FROM quotation.quotations q WHERE q.qt_id = v.qt_id);
-- 협력사 3곳 (admin 소유). 협상 세션의 supplier_id 가 참조한다.
INSERT INTO partner.suppliers (supplier_id, company_id, user_id, name, code, manager_name, manager_email, manager_contact_number, priority)
SELECT v.supplier_id, u.company_id, u.user_id, v.name, v.code, v.manager_name, v.manager_email, v.manager_contact_number, v.priority
FROM company.users u
CROSS JOIN (VALUES
('00000000-0000-0000-0000-000000000050'::uuid, '(주)우성테크놀로지', 'SUP-01', '김우성', 'woosung@vendor.dev', '02-500-1001', 'HIGH'),
('00000000-0000-0000-0000-000000000051'::uuid, '대현정밀공업(주)', 'SUP-02', '이대현', 'daehyun@vendor.dev', '02-500-1002', 'MEDIUM'),
('00000000-0000-0000-0000-000000000052'::uuid, '센서텍', 'SUP-03', '박센서', 'sensortech@vendor.dev', '02-500-1003', 'HIGH')
) AS v(supplier_id, name, code, manager_name, manager_email, manager_contact_number, priority)
WHERE u.id = 'admin' AND u.deleted = FALSE
AND NOT EXISTS (SELECT 1 FROM partner.suppliers s WHERE s.supplier_id = v.supplier_id);
-- 협상카드 2장 (admin 유저). 채팅에서 card_id 로 사용된다.
INSERT INTO card.nego_cards (nego_card_id, user_id, name, number, script)
SELECT v.nego_card_id, u.user_id, v.name, v.number, v.script
FROM company.users u
CROSS JOIN (VALUES
('00000000-0000-0000-0000-000000000060'::uuid, '즉시수용 인센티브', 'NC-01', '지금 수용하시면 차기 견적 우선참여와 추가 물량을 보장합니다.'),
('00000000-0000-0000-0000-000000000061'::uuid, '단가 매칭 보장', 'NC-02', '경쟁사 최저가에 맞춰주시면 즉시 낙찰 처리하겠습니다.')
) AS v(nego_card_id, name, number, script)
WHERE u.id = 'admin' AND u.deleted = FALSE
AND NOT EXISTS (SELECT 1 FROM card.nego_cards c WHERE c.nego_card_id = v.nego_card_id);
-- 협상 세션. status=SessionStatus(1=협상중 2=협상종료 3=협상거부), qt_type=QuotationType(1=재협상 2=재견적).
-- (sessions 엔 user_id 가 없어 고정 UUID 로 직접 삽입. 견적/상품/협력사 시드에 의존.)
INSERT INTO negotiation.sessions (
session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type,
target_price, status, bid_price, bid_at, end_time, reject_reason, reject_price, reject_delivery_type
)
VALUES
-- 견적 0041(배터리 재협상, 진행중) — 1:1 세션
('00000000-0000-0000-0000-000000000070', '00000000-0000-0000-0000-000000000041', '00000000-0000-0000-0000-000000000010', '00000000-0000-0000-0000-000000000050',
'EST-202606-1002', 2, 1, 1125000, 1, 1180000, now() - interval '1 day', now() + interval '3 day', NULL, NULL, NULL),
-- 견적 0042(MEMS 센서 재견적, 마감) — 1:N 세션 3개 (낙찰/동일가/거부)
('00000000-0000-0000-0000-000000000071', '00000000-0000-0000-0000-000000000042', '00000000-0000-0000-0000-000000000012', '00000000-0000-0000-0000-000000000052',
'EST-202606-1003', 1, 2, 7600, 2, 7300, now() - interval '2 day', now() - interval '1 day', NULL, NULL, NULL),
('00000000-0000-0000-0000-000000000072', '00000000-0000-0000-0000-000000000042', '00000000-0000-0000-0000-000000000012', '00000000-0000-0000-0000-000000000051',
'EST-202606-1003', 1, 2, 7600, 2, 7300, now() - interval '2 day', now() - interval '1 day', NULL, NULL, NULL),
('00000000-0000-0000-0000-000000000073', '00000000-0000-0000-0000-000000000042', '00000000-0000-0000-0000-000000000012', '00000000-0000-0000-0000-000000000050',
'EST-202606-1003', 1, 2, 7600, 3, NULL, NULL, now() - interval '1 day', '제조 원가 미달로 제시 단가 수용 불가', 8200, 2),
-- 견적 0043(로봇암 재협상, 보류) — 1:1 세션
('00000000-0000-0000-0000-000000000074', '00000000-0000-0000-0000-000000000043', '00000000-0000-0000-0000-000000000011', '00000000-0000-0000-0000-000000000050',
'EST-202606-1004', 1, 1, 17010000, 1, 18200000, now() - interval '2 day', now() + interval '2 day', NULL, NULL, NULL)
ON CONFLICT (session_id) DO NOTHING;
-- 협상 채팅. sender=ChatSender(1=봇 2=협력사), card_type=1=협상카드. card_used_yn=TRUE 인 행이 '사용 카드' 탭 소스.
INSERT INTO negotiation.chats (chat_id, session_id, card_id, seq, sender, target_price, card_used_yn, indicator_value, card_type)
VALUES
-- 세션 0070 (배터리)
('00000000-0000-0000-0000-000000000080', '00000000-0000-0000-0000-000000000070', NULL, 1, 1, 1125000, FALSE, NULL, NULL),
('00000000-0000-0000-0000-000000000081', '00000000-0000-0000-0000-000000000070', NULL, 2, 2, 1250000, FALSE, NULL, NULL),
('00000000-0000-0000-0000-000000000082', '00000000-0000-0000-0000-000000000070', '00000000-0000-0000-0000-000000000060', 3, 1, 1150000, TRUE, 0.08, 1),
('00000000-0000-0000-0000-000000000083', '00000000-0000-0000-0000-000000000070', NULL, 4, 2, 1180000, FALSE, NULL, NULL),
-- 세션 0071 (센서, 낙찰)
('00000000-0000-0000-0000-000000000084', '00000000-0000-0000-0000-000000000071', NULL, 1, 1, 7600, FALSE, NULL, NULL),
('00000000-0000-0000-0000-000000000085', '00000000-0000-0000-0000-000000000071', NULL, 2, 2, 7800, FALSE, NULL, NULL),
('00000000-0000-0000-0000-000000000086', '00000000-0000-0000-0000-000000000071', '00000000-0000-0000-0000-000000000061', 3, 1, 7400, TRUE, 0.05, 1),
('00000000-0000-0000-0000-000000000087', '00000000-0000-0000-0000-000000000071', NULL, 4, 2, 7300, FALSE, NULL, NULL),
-- 세션 0072 (센서, 동일가)
('00000000-0000-0000-0000-000000000088', '00000000-0000-0000-0000-000000000072', NULL, 1, 1, 7600, FALSE, NULL, NULL),
('00000000-0000-0000-0000-000000000089', '00000000-0000-0000-0000-000000000072', NULL, 2, 2, 7300, FALSE, NULL, NULL),
-- 세션 0073 (센서, 거부)
('00000000-0000-0000-0000-00000000008a', '00000000-0000-0000-0000-000000000073', NULL, 1, 1, 7600, FALSE, NULL, NULL),
('00000000-0000-0000-0000-00000000008b', '00000000-0000-0000-0000-000000000073', NULL, 2, 2, 8200, FALSE, NULL, NULL),
-- 세션 0074 (로봇암)
('00000000-0000-0000-0000-00000000008c', '00000000-0000-0000-0000-000000000074', NULL, 1, 1, 17010000, FALSE, NULL, NULL),
('00000000-0000-0000-0000-00000000008d', '00000000-0000-0000-0000-000000000074', '00000000-0000-0000-0000-000000000060', 2, 1, 17500000, TRUE, 0.06, 1),
('00000000-0000-0000-0000-00000000008e', '00000000-0000-0000-0000-000000000074', NULL, 3, 2, 18200000, FALSE, NULL, NULL)
ON CONFLICT (chat_id) DO NOTHING;
-- 와일드카드 2장 (admin 유저). 채팅에서 card_type=2 로 사용된다.
INSERT INTO card.wild_cards (wild_card_id, user_id, name, number, script, condition, available)
SELECT v.wild_card_id, u.user_id, v.name, v.number, v.script, v.condition, TRUE
FROM company.users u
CROSS JOIN (VALUES
('00000000-0000-0000-0000-000000000062'::uuid, '긴급마감 압박', 'WC-01', '오늘 자정까지 확정 시 단가를 동결하고 우선 배정합니다.', '마감 24시간 이내'),
('00000000-0000-0000-0000-000000000063'::uuid, '독점공급 보장', 'WC-02', '연간 독점 공급권을 보장하는 대신 추가 인하를 요청합니다.', '연간 계약 전환 시')
) AS v(wild_card_id, name, number, script, condition)
WHERE u.id = 'admin' AND u.deleted = FALSE
AND NOT EXISTS (SELECT 1 FROM card.wild_cards w WHERE w.wild_card_id = v.wild_card_id);
-- 세션마다 카드 사용 기록이 남도록 보강(0072 와일드 / 0073 협상 / 0074 와일드). card_type 1=협상 2=와일드.
INSERT INTO negotiation.chats (chat_id, session_id, card_id, seq, sender, target_price, card_used_yn, indicator_value, card_type)
VALUES
('00000000-0000-0000-0000-000000000090', '00000000-0000-0000-0000-000000000072', '00000000-0000-0000-0000-000000000062', 3, 1, 7500, TRUE, 0.04, 2),
('00000000-0000-0000-0000-000000000091', '00000000-0000-0000-0000-000000000073', '00000000-0000-0000-0000-000000000061', 3, 1, 7500, TRUE, 0.03, 1),
('00000000-0000-0000-0000-000000000092', '00000000-0000-0000-0000-000000000074', '00000000-0000-0000-0000-000000000063', 4, 1, 18000000, TRUE, 0.05, 2)
ON CONFLICT (chat_id) DO NOTHING;