Merge branch 'feature/negodata' into feature/landing

This commit is contained in:
Mina Choi 2026-07-22 15:24:56 +09:00
commit e67e713270
118 changed files with 2552 additions and 182 deletions

View File

@ -58,6 +58,21 @@ class suppliers(MAIN_BASE):
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부 deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
class companies(MAIN_BASE):
# company.companies (고객사). 공급사 포털 브랜딩(settings.branding) 조회 전용 미러.
@staticmethod
def DBType():
return DBType.PARTNER.value
__tablename__ = "companies"
__table_args__ = {"schema": "company"}
company_id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")) # 회사 식별자(PK)
name = Column(String(100), nullable=False) # 회사명
settings = Column(JSONB, nullable=True) # 회사별 커스터마이징(branding/labels 등, negodata 소유)
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
class items(MAIN_BASE): class items(MAIN_BASE):
# partner.items (상품). # partner.items (상품).
@staticmethod @staticmethod
@ -123,6 +138,7 @@ class sessions(MAIN_BASE):
reject_reason = Column(String(255), nullable=True) # 거절 사유 reject_reason = Column(String(255), nullable=True) # 거절 사유
reject_price = Column(BigInteger, nullable=True) # 거절 시 제시가(원) reject_price = Column(BigInteger, nullable=True) # 거절 시 제시가(원)
reject_delivery_type = Column(SmallInteger, nullable=True) # 거절 시 배송 유형 (코드) reject_delivery_type = Column(SmallInteger, nullable=True) # 거절 시 배송 유형 (코드)
custom = Column(JSONB, nullable=True) # 협상완료 부가정보 값 {key: value} (정의는 companies.settings.session_fields)
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC) created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC)
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"), onupdate=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, UPDATE 시 자동 갱신) updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"), onupdate=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, UPDATE 시 자동 갱신)
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부 deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
@ -183,6 +199,20 @@ class quotation_settings(MAIN_BASE):
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부 deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
class nego_cards(MAIN_BASE):
# card.nego_cards (협상카드). backend 는 번호→UUID 변환(chats.card_id 저장)만 위해 최소 컬럼 미러.
@staticmethod
def DBType():
return DBType.NEGOTIATION.value # 같은 negosium_db — chats 와 동일 세션풀로 조회
__tablename__ = "nego_cards"
__table_args__ = {"schema": "card"}
nego_card_id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"))
number = Column(String(10), nullable=True) # 카드 번호(agent turn.card_id 와 매칭)
deleted = Column(Boolean, nullable=False, server_default=text("false"))
class chats(MAIN_BASE): class chats(MAIN_BASE):
# negotiation.chats (협상 채팅 메시지 로그). session 1 : N chats. (session_id, seq) 유니크. # negotiation.chats (협상 채팅 메시지 로그). session 1 : N chats. (session_id, seq) 유니크.
@staticmethod @staticmethod

View File

@ -6,7 +6,7 @@ from sqlalchemy import asc, desc, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import chats, items, sessions from common.database.model.models import chats, items, sessions, nego_cards
from common.enums import ErrorType, SessionStatus from common.enums import ErrorType, SessionStatus
from common.logger import LOG from common.logger import LOG
@ -47,6 +47,10 @@ class IChatCRUD(ABC):
async def update_last_offer_price(self, cdb: AsyncSession, session_id, price: int) -> ErrorType: async def update_last_offer_price(self, cdb: AsyncSession, session_id, price: int) -> ErrorType:
pass pass
@abstractmethod
async def get_nego_card_id_by_number(self, cdb: AsyncSession, number: str):
pass
class ChatCRUD(IChatCRUD): class ChatCRUD(IChatCRUD):
async def list_by_session(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, list]: async def list_by_session(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, list]:
@ -112,6 +116,18 @@ class ChatCRUD(IChatCRUD):
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None return ErrorType.DB_RUN_FAILED, None
async def get_nego_card_id_by_number(self, cdb: AsyncSession, number: str):
# 협상카드 번호(agent turn.card_id) → nego_card_id(UUID). 없으면 None. 카드 사용 로그(chats.card_id) 저장용.
try:
query = select(nego_cards.nego_card_id).where(nego_cards.number == number, nego_cards.deleted == False).limit(1) # noqa: E712
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query, f"get_nego_card_id_by_number({number}) failed.")
if err_type != ErrorType.SUCCESS or not row_list:
return None
return row_list[0]
except Exception as ex:
LOG.e_no_callstack(ex)
return None
async def finalize_session( async def finalize_session(
self, cdb: AsyncSession, session_id, status: int, self, cdb: AsyncSession, session_id, status: int,
bid_price: Optional[int] = None, reject_reason: Optional[str] = None, reject_price: Optional[int] = None, bid_price: Optional[int] = None, reject_reason: Optional[str] = None, reject_price: Optional[int] = None,

View File

@ -41,6 +41,10 @@ class ISessionCRUD(ABC):
async def update_session_reject(self, cdb: AsyncSession, session_id, status: int, reject_reason: str) -> ErrorType: async def update_session_reject(self, cdb: AsyncSession, session_id, status: int, reject_reason: str) -> ErrorType:
pass pass
@abstractmethod
async def update_session_custom(self, cdb: AsyncSession, session_id, supplier_id, custom: dict) -> ErrorType:
pass
class SessionCRUD(ISessionCRUD): class SessionCRUD(ISessionCRUD):
@staticmethod @staticmethod
@ -86,6 +90,7 @@ class SessionCRUD(ISessionCRUD):
items.name, items.name,
items.model_name, items.model_name,
items.manufacturer, items.manufacturer,
sessions.custom,
) )
.join(items, items.item_id == sessions.item_id) .join(items, items.item_id == sessions.item_id)
.join(quotations, quotations.qt_id == sessions.quotation_id) .join(quotations, quotations.qt_id == sessions.quotation_id)
@ -173,3 +178,16 @@ class SessionCRUD(ISessionCRUD):
except Exception as ex: except Exception as ex:
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED return ErrorType.DB_RUN_FAILED
async def update_session_custom(self, cdb: AsyncSession, session_id, supplier_id, custom: dict) -> ErrorType:
# 협상완료 부가정보(sessions.custom) 저장. 본인 공급사 세션만(supplier_id 가드).
try:
query = (
update(sessions)
.where(sessions.session_id == session_id, sessions.supplier_id == supplier_id)
.values(custom=custom)
)
return await DB_SESSION_MNG.add(cdb, query)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED

View File

@ -5,7 +5,7 @@ from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import supplier_user_tokens, supplier_users, suppliers from common.database.model.models import supplier_user_tokens, supplier_users, suppliers, companies
from common.enums import ErrorType, TokenType from common.enums import ErrorType, TokenType
from common.logger import LOG from common.logger import LOG
from common.utils.gtime import GTime from common.utils.gtime import GTime
@ -28,6 +28,10 @@ class IUserCRUD(ABC):
async def get_supplier_name(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, str]: async def get_supplier_name(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, str]:
pass pass
@abstractmethod
async def get_company_settings(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, dict]:
pass
@abstractmethod @abstractmethod
async def is_account(self, cdb: AsyncSession, login_id: str) -> ErrorType: async def is_account(self, cdb: AsyncSession, login_id: str) -> ErrorType:
pass pass
@ -117,6 +121,25 @@ class UserCRUD(IUserCRUD):
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None return ErrorType.DB_RUN_FAILED, None
async def get_company_settings(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, dict]:
"""공급사 소속 회사 설정(companies.settings) 전체. 브랜딩·협상완료 필드 등이 들어있다. 미설정이면 빈 dict."""
try:
query = (
select(companies.settings)
.join(suppliers, suppliers.company_id == companies.company_id)
.where(suppliers.supplier_id == supplier_id, suppliers.deleted == False, companies.deleted == False) # noqa: E712
.limit(1)
)
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query, f"get_company_settings(supplier_id:{supplier_id}) failed.")
if err_type != ErrorType.SUCCESS:
return err_type, {}
settings = row_list[0] if row_list else None
return ErrorType.SUCCESS, settings or {}
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, {}
async def is_account(self, cdb: AsyncSession, login_id: str) -> ErrorType: async def is_account(self, cdb: AsyncSession, login_id: str) -> ErrorType:
try: try:
query = ( query = (

View File

@ -48,6 +48,8 @@ class Res_Me(Res_WebPacketProtocol):
supplier_id: str = Field("", description="소속 공급사 uuid") supplier_id: str = Field("", description="소속 공급사 uuid")
supplier_name: str = Field("", description="공급사명") supplier_name: str = Field("", description="공급사명")
role: int = Field(0, description="권한 코드 1=user, 2=manager (UserRole)") role: int = Field(0, description="권한 코드 1=user, 2=manager (UserRole)")
branding: dict = Field(default_factory=dict, description="소속 회사 브랜딩(companies.settings.branding). 서비스명/로고/색")
session_fields: list = Field(default_factory=list, description="협상완료 부가정보 필드 정의(companies.settings.session_fields). 공급사가 타결 후 입력")
class Res_Logout(Res_WebPacketProtocol): class Res_Logout(Res_WebPacketProtocol):

View File

@ -77,6 +77,7 @@ class Res_ChatInit(Res_WebPacketProtocol):
item_min_order_quantity: str = Field("", description="최소 주문 수량") item_min_order_quantity: str = Field("", description="최소 주문 수량")
item_vat_yn: Optional[bool] = Field(None, description="VAT 포함 여부(미설정 시 null)") item_vat_yn: Optional[bool] = Field(None, description="VAT 포함 여부(미설정 시 null)")
item_delivery_fee_yn: Optional[bool] = Field(None, description="배송비 포함 여부(미설정 시 null)") item_delivery_fee_yn: Optional[bool] = Field(None, description="배송비 포함 여부(미설정 시 null)")
custom: dict = Field(default_factory=dict, description="협상완료 부가정보 기존 입력값(sessions.custom). 재진입 시 폼 프리필용")
# 대화 히스토리(재진입 복원) # 대화 히스토리(재진입 복원)

View File

@ -14,6 +14,7 @@ class ListItem(WebPacketProtocol):
item_name: str = Field("", description="상품명") item_name: str = Field("", description="상품명")
model_name: str = Field("", description="모델명") model_name: str = Field("", description="모델명")
maker_name: str = Field("", description="제조사") maker_name: str = Field("", description="제조사")
custom: dict = Field(default_factory=dict, description="협상완료 부가정보 값(sessions.custom). 미입력이면 빈 dict")
class Res_SessionList(Res_WebPacketProtocol): class Res_SessionList(Res_WebPacketProtocol):
@ -33,3 +34,11 @@ class Req_Reject(WebPacketProtocol):
class Res_Reject(Res_WebPacketProtocol): class Res_Reject(Res_WebPacketProtocol):
session_id: str = Field("", description="거부 처리된 세션 uuid") session_id: str = Field("", description="거부 처리된 세션 uuid")
class Req_ExtraInfo(WebPacketProtocol):
custom: dict = Field(default_factory=dict, description="협상완료 부가정보 값 {key: value} (회사 정의 session_fields 대로)")
class Res_ExtraInfo(Res_WebPacketProtocol):
session_id: str = Field("", description="부가정보 저장된 세션 uuid")

View File

@ -6,7 +6,7 @@ from fastapi.security import HTTPAuthorizationCredentials
from common.models.gmodel import UserInfo from common.models.gmodel import UserInfo
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse, security from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse, security
from services.negotiation_service import NegotiationService from services.negotiation_service import NegotiationService
from .protocol import Req_Reject, Res_Participate, Res_Reject, Res_SessionList from .protocol import Req_ExtraInfo, Req_Reject, Res_ExtraInfo, Res_Participate, Res_Reject, Res_SessionList
router = APIRouter(prefix="/v1/negotiation", tags=["Negotiation"], responses={404: {"description": "Not found"}}) router = APIRouter(prefix="/v1/negotiation", tags=["Negotiation"], responses={404: {"description": "Not found"}})
@ -61,3 +61,19 @@ async def reject(
service: NegotiationService = Depends(), service: NegotiationService = Depends(),
): ):
return RemoveNoneResponse(await service.reject(user_info, credentials.credentials, session_id, req.reject_reason)) return RemoveNoneResponse(await service.reject(user_info, credentials.credentials, session_id, req.reject_reason))
@router.post(
path="/sessions/{session_id}/extra-info",
response_model=Res_ExtraInfo,
summary="협상완료 부가정보 저장",
description="협상 타결(완료) 세션에 부가정보(표준납기/MOQ/발주배수/배송유형 등, 회사 정의 session_fields)를 저장한다. 본인 공급사의 완료 세션만 허용.",
)
async def save_extra_info(
session_id: str = Path(description="대상 협상 세션 uuid"),
req: Req_ExtraInfo = ...,
user_info: UserInfo = Depends(IsValidAccessToken),
credentials: HTTPAuthorizationCredentials = Depends(security),
service: NegotiationService = Depends(),
):
return RemoveNoneResponse(await service.save_extra_info(user_info, credentials.credentials, session_id, req))

View File

@ -250,6 +250,15 @@ class AuthService:
res.supplier_id = info.supplier_id res.supplier_id = info.supplier_id
res.supplier_name = info.supplier_name res.supplier_name = info.supplier_name
res.role = info.role res.role = info.role
# 소속 회사 설정(companies.settings) — 로고/서비스명(branding) + 협상완료 부가필드(session_fields). 실패해도 기본값.
_e, settings = await DB_SESSION_MNG.execute_lambda(
suppliers.DBType(),
DBWRType.DB_READ.value,
lambda s: self.user_crud.get_company_settings(s, uuid.UUID(info.supplier_id)),
)
settings = settings or {}
res.branding = settings.get("branding") or {}
res.session_fields = settings.get("session_fields") or []
return res return res
async def popup_status(self, user_info: UserInfo, access_token: str) -> Res_PopupStatus: async def popup_status(self, user_info: UserInfo, access_token: str) -> Res_PopupStatus:

View File

@ -103,13 +103,18 @@ class ChatService:
) )
@staticmethod @staticmethod
def _build_bot_chat(sess, seq: int, turn, bot_chat_type: Optional[str] = None, summary: Optional[dict] = None) -> chats: def _build_bot_chat(sess, seq: int, turn, bot_chat_type: Optional[str] = None, summary: Optional[dict] = None, nego_card_uuid=None) -> chats:
# bot_chat_type/summary 도 meta 에 영속화 → 히스토리 복원 시 폼 재현. indicator_value 는 전용 컬럼에도 적재. # bot_chat_type/summary 도 meta 에 영속화 → 히스토리 복원 시 폼 재현. indicator_value 는 전용 컬럼에도 적재.
# nego_card_uuid: turn.card_id(번호)를 UUID 로 변환한 값(nego 카드). 있으면 chats.card_id/card_type/card_used_yn 컬럼에 적재
# → negodata 가 이 컬럼으로 카드 사용/효과를 조인한다. (wild 카드는 agent 가 card_id 미제공 — 별도 작업)
return chats( return chats(
chat_id=uuid.uuid4(), session_id=sess.session_id, seq=seq, chat_id=uuid.uuid4(), session_id=sess.session_id, seq=seq,
sender=ChatSender.BOT.value, sender=ChatSender.BOT.value,
target_price=int(sess.target_price or 0), target_price=int(sess.target_price or 0),
indicator_value=turn.indicator_value, indicator_value=turn.indicator_value,
card_id=nego_card_uuid,
card_type=1 if nego_card_uuid else None, # CardType: 1=nego (wild=2 는 별도)
card_used_yn=True if nego_card_uuid else None,
meta={ meta={
"script": turn.script, "step": turn.step, "client_step": turn.client_step, "script": turn.script, "step": turn.step, "client_step": turn.client_step,
"input_mode": turn.input_mode, "input_options": turn.input_options, "input_mode": turn.input_mode, "input_options": turn.input_options,
@ -221,6 +226,7 @@ class ChatService:
res.item_min_order_quantity = item.moq or "" res.item_min_order_quantity = item.moq or ""
res.item_vat_yn = item.vat_yn res.item_vat_yn = item.vat_yn
res.item_delivery_fee_yn = item.delivery_fee_yn res.item_delivery_fee_yn = item.delivery_fee_yn
res.custom = sess.custom or {}
return res return res
# ---- messages ------------------------------------------------------- # ---- messages -------------------------------------------------------
@ -371,8 +377,16 @@ class ChatService:
# 유저 미입력 가격 타결 케이스 — 마지막 유저 제시가와 다를 수 있다). # 유저 미입력 가격 타결 케이스 — 마지막 유저 제시가와 다를 수 있다).
summary = await self._build_summary(sess, quote, item, final_price, turn.settled_price or last_price) summary = await self._build_summary(sess, quote, item, final_price, turn.settled_price or last_price)
# nego 카드 번호(turn.card_id) → UUID 변환. 카드 사용 로그(chats.card_id/type/used)를 negodata 조인용으로 남긴다.
nego_card_uuid = None
if turn.card_id:
nego_card_uuid = await DB_SESSION_MNG.execute_lambda(
chats.DBType(), DBWRType.DB_READ.value,
lambda s: self.chat_crud.get_nego_card_id_by_number(s, str(turn.card_id)),
)
# 봇 메시지 + 종료 시 확정(성공=DONE+입찰가 / 실패=REJECTED+거부사유·제시가). 한 트랜잭션. # 봇 메시지 + 종료 시 확정(성공=DONE+입찰가 / 실패=REJECTED+거부사유·제시가). 한 트랜잭션.
bot_msg = self._build_bot_chat(sess, seq=max_seq + 2, turn=turn, bot_chat_type=bot_chat_type, summary=summary) bot_msg = self._build_bot_chat(sess, seq=max_seq + 2, turn=turn, bot_chat_type=bot_chat_type, summary=summary, nego_card_uuid=nego_card_uuid)
funcs = [lambda s: self.chat_crud.insert_message(s, bot_msg)] funcs = [lambda s: self.chat_crud.insert_message(s, bot_msg)]
# 가격 입력 턴 → 마지막 제시가를 봇 메시지 저장과 같은 트랜잭션으로 갱신. # 가격 입력 턴 → 마지막 제시가를 봇 메시지 저장과 같은 트랜잭션으로 갱신.
# 앵커링 표본 판정의 "가격 흔적"(가격을 써낸 협상만 집계 — 중간 이탈해도 실패로 측정 가능). # 앵커링 표본 판정의 "가격 흔적"(가격을 써낸 협상만 집계 — 중간 이탈해도 실패로 측정 가능).

View File

@ -8,7 +8,7 @@ from common.database.model.models import sessions
from common.enums import DBWRType, ErrorType, QuotationStatus, SessionStatus from common.enums import DBWRType, ErrorType, QuotationStatus, SessionStatus
from common.models.gmodel import UserInfo from common.models.gmodel import UserInfo
from crud.session_crud import ISessionCRUD, SessionCRUD from crud.session_crud import ISessionCRUD, SessionCRUD
from router.v1.negotiation.protocol import ListItem, Res_Participate, Res_Reject, Res_SessionList from router.v1.negotiation.protocol import ListItem, Req_ExtraInfo, Res_ExtraInfo, Res_Participate, Res_Reject, Res_SessionList
from services.auth_service import AuthService from services.auth_service import AuthService
@ -65,6 +65,7 @@ class NegotiationService:
item_name=r[6] or "", item_name=r[6] or "",
model_name=r[7] or "", model_name=r[7] or "",
maker_name=r[8] or "", maker_name=r[8] or "",
custom=r[9] or {},
) )
for r in rows for r in rows
] ]
@ -73,6 +74,53 @@ class NegotiationService:
res.page_size = page_size res.page_size = page_size
return res return res
async def save_extra_info(self, user_info: UserInfo, access_token: str, session_id_str: str, req: Req_ExtraInfo) -> Res_ExtraInfo:
"""협상완료(타결) 부가정보 저장. 견적 마감 여부와 무관하게, 본인 공급사의 '협상완료' 세션에만 허용.
_load_actionable_session 견적마감·마감시간을 막으므로(타결 후엔 마감됐을 있음) 쓰지 않고 직접 검증한다.
"""
res = Res_ExtraInfo()
# 1) 인증
err_type, info = await self.auth.authenticate(user_info, access_token)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
try:
session_id = uuid.UUID(session_id_str)
except (ValueError, TypeError):
res.result.SetResult(ErrorType.NEGO_NOT_FOUND)
return res
# 2) 세션 조회 + 소유 검증
err_type, sess = await DB_SESSION_MNG.execute_lambda(
sessions.DBType(), DBWRType.DB_READ.value,
lambda s: self.session_crud.get_session_by_id(s, session_id),
)
if err_type != ErrorType.SUCCESS or sess is None:
res.result.SetResult(ErrorType.NEGO_NOT_FOUND)
return res
if str(sess.supplier_id) != info.supplier_id:
res.result.SetResult(ErrorType.NEGO_FORBIDDEN)
return res
# 3) 협상완료(타결) 세션만 부가정보 입력 허용
if sess.status != SessionStatus.DONE.value:
res.result.SetResult(ErrorType.NEGO_NOT_PARTICIPABLE)
return res
# 4) 저장(supplier_id 가드 crud)
err_type = await DB_SESSION_MNG.execute_lambda_run(
[sessions.DBType()],
[lambda s: self.session_crud.update_session_custom(s, session_id, uuid.UUID(info.supplier_id), req.custom or {})],
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
res.session_id = str(session_id)
return res
async def _load_actionable_session(self, user_info: UserInfo, access_token: str, session_id_str: str, blocked_statuses: tuple): async def _load_actionable_session(self, user_info: UserInfo, access_token: str, session_id_str: str, blocked_statuses: tuple):
"""참여/거부 공통 전처리: 인증 → 세션/견적 로드 → 소유·상태·견적마감·마감시간 검증. """참여/거부 공통 전처리: 인증 → 세션/견적 로드 → 소유·상태·견적마감·마감시간 검증.
성공 (SUCCESS, sess, quote), 실패 (err_type, None, None) 반환한다. 성공 (SUCCESS, sess, quote), 실패 (err_type, None, None) 반환한다.

View File

@ -54,6 +54,20 @@ export interface RefreshTokenResponse {
} }
// --- 내 정보 (GET /v1/auth/me) ------------------------------------------- // --- 내 정보 (GET /v1/auth/me) -------------------------------------------
export interface Branding {
service_name?: string
logo_url?: string
primary_color?: string
email_header?: string
}
// 협상완료 부가정보 필드 정의(companies.settings.session_fields)
export interface SessionField {
key: string
label: string
type: 'text' | 'number' | 'boolean'
}
export interface MeResponse { export interface MeResponse {
result: ApiResult result: ApiResult
su_id: string su_id: string
@ -62,6 +76,8 @@ export interface MeResponse {
supplier_id: string supplier_id: string
supplier_name: string supplier_name: string
role: number role: number
branding?: Branding
session_fields?: SessionField[]
} }
// --- 로그아웃 ------------------------------------------------------------- // --- 로그아웃 -------------------------------------------------------------
@ -94,6 +110,8 @@ export interface AuthUser {
supplierId: string supplierId: string
supplierName: string supplierName: string
role: number role: number
branding: Branding
sessionFields: SessionField[]
} }
export function toAuthUser(res: MeResponse): AuthUser { export function toAuthUser(res: MeResponse): AuthUser {
@ -104,5 +122,7 @@ export function toAuthUser(res: MeResponse): AuthUser {
supplierId: res.supplier_id, supplierId: res.supplier_id,
supplierName: res.supplier_name, supplierName: res.supplier_name,
role: res.role, role: res.role,
branding: res.branding ?? {},
sessionFields: res.session_fields ?? [],
} }
} }

View File

@ -46,6 +46,7 @@ export interface ChatInitResponse {
item_min_order_quantity?: string item_min_order_quantity?: string
item_vat_yn?: boolean item_vat_yn?: boolean
item_delivery_fee_yn?: boolean item_delivery_fee_yn?: boolean
custom?: Record<string, unknown>
} }
export interface ChatMessagesResponse { export interface ChatMessagesResponse {
@ -98,6 +99,7 @@ export function mapInit(r: ChatInitResponse): ChatInitData {
item_delivery_fee_yn: item_delivery_fee_yn:
r.item_delivery_fee_yn == null ? '' : r.item_delivery_fee_yn ? '배송비포함' : '배송비별도', r.item_delivery_fee_yn == null ? '' : r.item_delivery_fee_yn ? '배송비포함' : '배송비별도',
item_min_order_quantity: r.item_min_order_quantity ?? '', item_min_order_quantity: r.item_min_order_quantity ?? '',
custom: r.custom ?? {},
item_lead_time: r.item_lead_time ?? '', item_lead_time: r.item_lead_time ?? '',
item_spec: r.item_spec ?? '', item_spec: r.item_spec ?? '',
quotation_memo: r.quotation_memo ?? '', quotation_memo: r.quotation_memo ?? '',

View File

@ -2,5 +2,5 @@
export { negotiationApi } from './negotiation.api' export { negotiationApi } from './negotiation.api'
export { negotiationKeys } from './negotiation.keys' export { negotiationKeys } from './negotiation.keys'
export { useSessionListQuery } from './negotiation.queries' export { useSessionListQuery } from './negotiation.queries'
export { useParticipateMutation, useRejectMutation } from './negotiation.mutations' export { useParticipateMutation, useRejectMutation, useSaveExtraInfoMutation } from './negotiation.mutations'
export * from './negotiation.type' export * from './negotiation.type'

View File

@ -1,6 +1,8 @@
// 협상 엔드포인트 호출 함수 (순수 HTTP 레이어, React 의존 없음). // 협상 엔드포인트 호출 함수 (순수 HTTP 레이어, React 의존 없음).
import { http } from '@/apis/http' import { http } from '@/apis/http'
import type { import type {
ExtraInfoRequest,
ExtraInfoResponse,
ParticipateResponse, ParticipateResponse,
RejectRequest, RejectRequest,
RejectResponse, RejectResponse,
@ -31,4 +33,13 @@ export const negotiationApi = {
) )
return res.data return res.data
}, },
/** POST /v1/negotiation/sessions/{id}/extra-info — 협상완료 부가정보 저장 */
saveExtraInfo: async (sessionId: string, body: ExtraInfoRequest): Promise<ExtraInfoResponse> => {
const res = await http.post<ExtraInfoResponse>(
`/v1/negotiation/sessions/${sessionId}/extra-info`,
body,
)
return res.data
},
} }

View File

@ -2,7 +2,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query' import { useMutation, useQueryClient } from '@tanstack/react-query'
import { negotiationApi } from './negotiation.api' import { negotiationApi } from './negotiation.api'
import { negotiationKeys } from './negotiation.keys' import { negotiationKeys } from './negotiation.keys'
import type { RejectRequest } from './negotiation.type' import type { ExtraInfoRequest, RejectRequest } from './negotiation.type'
/** /**
* 참여: 성공 . * 참여: 성공 .
@ -30,3 +30,17 @@ export function useRejectMutation() {
}, },
}) })
} }
/**
* 저장: 성공 .
*/
export function useSaveExtraInfoMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ sessionId, request }: { sessionId: string; request: ExtraInfoRequest }) =>
negotiationApi.saveExtraInfo(sessionId, request),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: negotiationKeys.sessions() })
},
})
}

View File

@ -55,6 +55,7 @@ export interface SessionListItem {
item_name: string item_name: string
model_name: string model_name: string
maker_name: string maker_name: string
custom: Record<string, unknown> // 협상완료 부가정보(sessions.custom). 미입력이면 {}
} }
export interface SessionListResponse { export interface SessionListResponse {
@ -65,6 +66,15 @@ export interface SessionListResponse {
page_size: number page_size: number
} }
// --- 협상완료 부가정보 (POST /v1/negotiation/sessions/{id}/extra-info) -----
export interface ExtraInfoRequest {
custom: Record<string, unknown>
}
export interface ExtraInfoResponse {
result: ApiResult
session_id: string
}
// --- 참여 (POST /v1/negotiation/sessions/{id}/participate) ---------------- // --- 참여 (POST /v1/negotiation/sessions/{id}/participate) ----------------
export interface ParticipateResponse { export interface ParticipateResponse {
result: ApiResult result: ApiResult

View File

@ -22,6 +22,9 @@ export interface LogoProps extends Omit<ComponentProps<'div'>, 'children'> {
size?: LogoSize size?: LogoSize
withText?: boolean withText?: boolean
alt?: string alt?: string
/** 회사 브랜딩 오버라이드(companies.settings.branding). 없으면 기본 iMarket Korea. */
serviceName?: string
logoUrl?: string
} }
export function Logo({ export function Logo({
@ -29,21 +32,24 @@ export function Logo({
size = 'md', size = 'md',
withText = true, withText = true,
alt = 'iMarket Korea', alt = 'iMarket Korea',
serviceName,
logoUrl,
className, className,
...props ...props
}: LogoProps) { }: LogoProps) {
const s = sizes[size] const s = sizes[size]
const name = serviceName || 'iMarket Korea'
return ( return (
<div className={cn('flex items-center', s.gap, className)} {...props}> <div className={cn('flex items-center', s.gap, className)} {...props}>
<img <img
src={sources[variant]} src={logoUrl || sources[variant]}
alt={withText ? '' : alt} alt={withText ? '' : (serviceName || alt)}
className={cn('w-auto select-none', s.img)} className={cn('w-auto select-none', s.img)}
/> />
{withText && ( {withText && (
<span className={cn('font-bold tracking-[-0.4px] text-foreground', s.text)}> <span className={cn('font-bold tracking-[-0.4px] text-foreground', s.text)}>
iMarket Korea {name}
</span> </span>
)} )}
</div> </div>

View File

@ -6,6 +6,7 @@ import type { ChatMessage as ChatMessageType } from '@/features/chat/types'
import { renderEmphasis } from '@/features/chat/lib/emphasis' import { renderEmphasis } from '@/features/chat/lib/emphasis'
import { Indicator } from '@/features/chat/components/templates/Indicator' import { Indicator } from '@/features/chat/components/templates/Indicator'
import { Summary } from '@/features/chat/components/templates/Summary' import { Summary } from '@/features/chat/components/templates/Summary'
import { ExtraInfoForm } from '@/features/chat/components/templates/ExtraInfoForm'
import { BidSummary } from '@/features/chat/components/templates/BidSummary' import { BidSummary } from '@/features/chat/components/templates/BidSummary'
import { RejectRSP } from '@/features/chat/components/templates/RejectRSP' import { RejectRSP } from '@/features/chat/components/templates/RejectRSP'
import { RejectCM } from '@/features/chat/components/templates/RejectCM' import { RejectCM } from '@/features/chat/components/templates/RejectCM'
@ -108,7 +109,12 @@ const BotMessage = memo(function BotMessage({ message }: { message: ChatMessageT
{showIndicator && message.bot_chat_type === 'indicator' && message.indicator_value != null && ( {showIndicator && message.bot_chat_type === 'indicator' && message.indicator_value != null && (
<Indicator number={message.indicator_value} /> <Indicator number={message.indicator_value} />
)} )}
{message.bot_chat_type === 'summaryRSP' && message.summary && <Summary data={message.summary} />} {message.bot_chat_type === 'summaryRSP' && message.summary && (
<>
<Summary data={message.summary} />
<ExtraInfoForm />
</>
)}
{message.bot_chat_type === 'summaryCM' && message.summary && ( {message.bot_chat_type === 'summaryCM' && message.summary && (
<BidSummary <BidSummary
itemName={message.summary.item_name} itemName={message.summary.item_name}

View File

@ -0,0 +1,109 @@
import { useEffect, useState } from 'react'
import { CheckCircle2 } from 'lucide-react'
import { toast } from 'sonner'
import { useMeQuery, useSaveExtraInfoMutation, getApiErrorMessage } from '@/apis'
import type { SessionField } from '@/apis/auth/auth.type'
import { useChatStore } from '@/features/chat/stores/useChatStore'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
// 협상 타결(Summary) 직후 부가정보 입력 폼. 필드 정의(session_fields)는 회사 설정(/me)에서 온다.
// 저장하면 sessions.custom 에 기록되고 negodata 견적상세에 표시된다. 정의가 없으면 렌더하지 않는다.
export function ExtraInfoForm() {
const sessionId = useChatStore((s) => s.sessionId)
const { data: user } = useMeQuery()
const fields: SessionField[] = user?.sessionFields ?? []
const save = useSaveExtraInfoMutation()
const existing = useChatInitStore((s) => s.custom) // 기존 입력값(재진입 프리필)
const [values, setValues] = useState<Record<string, unknown>>({})
const [inited, setInited] = useState(false)
const [saved, setSaved] = useState(false)
// fields(회사 설정)와 기존값(sessions.custom)이 준비된 첫 시점에 프리필 — 이후 사용자 편집은 보존.
useEffect(() => {
if (inited || fields.length === 0) return
const init: Record<string, unknown> = {}
for (const f of fields) init[f.key] = existing?.[f.key] ?? (f.type === 'boolean' ? false : '')
setValues(init)
setInited(true)
}, [inited, fields, existing])
if (fields.length === 0) return null
const set = (key: string, value: unknown) => setValues((v) => ({ ...v, [key]: value }))
const handleSave = () => {
const custom: Record<string, unknown> = {}
for (const f of fields) {
const v = values[f.key]
if (f.type === 'boolean') custom[f.key] = !!v
else if (v !== '' && v != null) custom[f.key] = f.type === 'number' ? Number(v) : v
}
save.mutate(
{ sessionId, request: { custom } },
{
onSuccess: () => {
setSaved(true)
toast.success('부가정보가 저장되었습니다.')
},
onError: (error) => toast.error(getApiErrorMessage(error, '부가정보 저장에 실패했습니다.')),
},
)
}
return (
<div className="w-full rounded-2xl border border-border bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center gap-2">
<CheckCircle2 className="size-5 text-brand-600" />
<h3 className="text-sm font-bold text-neutral-90"> </h3>
</div>
<p className="text-sm leading-relaxed text-neutral-70 break-keep">
. . ( .)
</p>
<div className="mt-4 space-y-4">
{fields.map((f) => (
<div key={f.key} className="space-y-1.5">
<label className="block text-sm font-semibold text-neutral-80">{f.label}</label>
{f.type === 'boolean' ? (
<button
type="button"
role="switch"
aria-checked={!!values[f.key]}
onClick={() => set(f.key, !values[f.key])}
disabled={saved}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-50 ${
values[f.key] ? 'bg-brand-600' : 'bg-neutral-30'
}`}
>
<span
className={`inline-block size-5 transform rounded-full bg-white shadow transition-transform ${
values[f.key] ? 'translate-x-5' : 'translate-x-0.5'
}`}
/>
</button>
) : (
<input
type={f.type === 'number' ? 'number' : 'text'}
value={String(values[f.key] ?? '')}
onChange={(e) => set(f.key, e.target.value)}
disabled={saved}
className="h-11 w-full rounded-xl border border-border bg-white px-3 text-sm outline-none focus:border-brand-600 focus:ring-1 focus:ring-brand-600 disabled:bg-neutral-10"
placeholder={f.label}
/>
)}
</div>
))}
</div>
<button
type="button"
onClick={handleSave}
disabled={save.isPending || saved}
className="mt-4 h-11 w-full rounded-xl bg-brand-600 text-sm font-bold text-white transition-colors hover:bg-brand-700 disabled:opacity-50"
>
{saved ? '저장 완료' : save.isPending ? '저장 중…' : '부가정보 저장'}
</button>
</div>
)
}

View File

@ -29,6 +29,7 @@ const initialState: ChatInitData = {
item_spec: '', item_spec: '',
quotation_memo: '', quotation_memo: '',
quotation_end_time: '', quotation_end_time: '',
custom: {},
} }
export const useChatInitStore = create<ChatInitStore>((set) => ({ export const useChatInitStore = create<ChatInitStore>((set) => ({

View File

@ -81,4 +81,5 @@ export type ChatInitData = {
item_spec: string item_spec: string
quotation_memo: string quotation_memo: string
quotation_end_time: string quotation_end_time: string
custom: Record<string, unknown> // 협상완료 부가정보 기존 입력값(프리필용)
} }

View File

@ -0,0 +1,115 @@
import { useState } from 'react'
import { X } from 'lucide-react'
import { Modal } from '@/components'
import { useMeQuery } from '@/apis'
import type { SessionField } from '@/apis/auth/auth.type'
import type { ListItem } from '../types'
export interface ExtraInfoPopupProps {
target: ListItem
onClose: () => void
/** 부가정보 저장 (custom = {key: value}) */
onSubmit: (custom: Record<string, unknown>) => void
}
// 협상완료 부가정보 입력 팝업. 필드 정의(session_fields)는 회사 설정(/me)에서 오고, 기존 입력값(target.custom)으로 프리필.
export function ExtraInfoPopup({ target, onClose, onSubmit }: ExtraInfoPopupProps) {
const { data: user } = useMeQuery()
const fields: SessionField[] = user?.sessionFields ?? []
const [values, setValues] = useState<Record<string, unknown>>(() => {
const init: Record<string, unknown> = {}
for (const f of fields) init[f.key] = target.custom?.[f.key] ?? (f.type === 'boolean' ? false : '')
return init
})
const set = (key: string, value: unknown) => setValues((v) => ({ ...v, [key]: value }))
const handleSubmit = () => {
// 빈 값은 제외하고 저장(부분 입력 허용)
const out: Record<string, unknown> = {}
for (const f of fields) {
const v = values[f.key]
if (f.type === 'boolean') out[f.key] = !!v
else if (v !== '' && v != null) out[f.key] = f.type === 'number' ? Number(v) : v
}
onSubmit(out)
onClose()
}
return (
<Modal onClose={onClose}>
<div className="w-full max-w-md overflow-hidden rounded-2xl border border-border bg-white shadow-xl animate-scale-in">
<div className="flex items-center justify-between border-b border-border p-5">
<div>
<h3 className="text-base font-bold text-neutral-90"> </h3>
<p className="mt-0.5 text-xs text-neutral-60">{target.qt_number} · {target.item_name}</p>
</div>
<button
type="button"
onClick={onClose}
aria-label="닫기"
className="flex size-8 items-center justify-center rounded-full text-neutral-60 hover:bg-neutral-10"
>
<X className="size-4" />
</button>
</div>
<div className="space-y-4 p-5">
{fields.length === 0 ? (
<p className="py-6 text-center text-sm text-neutral-60"> .</p>
) : (
fields.map((f) => (
<div key={f.key} className="space-y-1.5">
<label className="block text-sm font-semibold text-neutral-80">{f.label}</label>
{f.type === 'boolean' ? (
<button
type="button"
role="switch"
aria-checked={!!values[f.key]}
onClick={() => set(f.key, !values[f.key])}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
values[f.key] ? 'bg-brand-600' : 'bg-neutral-30'
}`}
>
<span
className={`inline-block size-5 transform rounded-full bg-white shadow transition-transform ${
values[f.key] ? 'translate-x-5' : 'translate-x-0.5'
}`}
/>
</button>
) : (
<input
type={f.type === 'number' ? 'number' : 'text'}
value={String(values[f.key] ?? '')}
onChange={(e) => set(f.key, e.target.value)}
className="h-11 w-full rounded-xl border border-border bg-white px-3 text-sm outline-none focus:border-brand-600 focus:ring-1 focus:ring-brand-600"
placeholder={f.label}
/>
)}
</div>
))
)}
</div>
<div className="flex gap-2 border-t border-border p-5">
<button
type="button"
onClick={onClose}
className="h-11 flex-1 rounded-xl border border-border text-sm font-bold text-neutral-70 hover:bg-neutral-10"
>
</button>
<button
type="button"
onClick={handleSubmit}
disabled={fields.length === 0}
className="h-11 flex-1 rounded-xl bg-brand-600 text-sm font-bold text-white hover:bg-brand-700 disabled:opacity-40"
>
</button>
</div>
</div>
</Modal>
)
}

View File

@ -9,10 +9,11 @@ interface WorkspaceCardsProps {
busyId?: string | null busyId?: string | null
onEnter: (item: ListItem) => void onEnter: (item: ListItem) => void
onReject: (item: ListItem) => void onReject: (item: ListItem) => void
onExtraInfo: (item: ListItem) => void
} }
// 모바일(lg 미만) 협상 목록: 테이블 대신 카드 스택. // 모바일(lg 미만) 협상 목록: 테이블 대신 카드 스택.
export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject }: WorkspaceCardsProps) { export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject, onExtraInfo }: WorkspaceCardsProps) {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center py-16"> <div className="flex items-center justify-center py-16">
@ -27,7 +28,7 @@ export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject }:
return ( return (
<div className="divide-y divide-border"> <div className="divide-y divide-border">
{items.map((item) => ( {items.map((item) => (
<Card key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} /> <Card key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} onExtraInfo={onExtraInfo} />
))} ))}
</div> </div>
) )
@ -38,25 +39,26 @@ function Card({
busy, busy,
onEnter, onEnter,
onReject, onReject,
onExtraInfo,
}: { }: {
item: ListItem item: ListItem
busy: boolean busy: boolean
onEnter: (item: ListItem) => void onEnter: (item: ListItem) => void
onReject: (item: ListItem) => void onReject: (item: ListItem) => void
onExtraInfo: (item: ListItem) => void
}) { }) {
const meta = statusMeta(item.session_status) const meta = statusMeta(item.session_status)
const canEnter = !['미참여', '협상거부'].includes(item.session_status) const canEnter = !['미참여', '협상거부'].includes(item.session_status)
const canReject = ['협상생성', '협상중'].includes(item.session_status) const canReject = ['협상생성', '협상중'].includes(item.session_status)
const enterLabel = item.session_status === '협상완료' ? '결과 보기' : '협상 입장' const isDone = item.session_status === '협상완료'
const enterLabel = isDone ? '결과 보기' : '협상 입장'
const hasExtra = item.custom && Object.keys(item.custom).length > 0
return ( return (
<div className="p-4"> <div className="p-4">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="rounded-md bg-neutral-20 px-1.5 py-0.5 text-[11px] font-semibold text-neutral-70">
{item.qt_type || '-'}
</span>
<span className="truncate font-mono text-[12px] font-semibold text-neutral-70">{item.qt_number || '-'}</span> <span className="truncate font-mono text-[12px] font-semibold text-neutral-70">{item.qt_number || '-'}</span>
</div> </div>
<p className="mt-1.5 truncate text-sm font-bold text-neutral-90">{item.item_name || '-'}</p> <p className="mt-1.5 truncate text-sm font-bold text-neutral-90">{item.item_name || '-'}</p>
@ -76,8 +78,17 @@ function Card({
<span> {formatKstDateTime(item.qt_end_time)}</span> <span> {formatKstDateTime(item.qt_end_time)}</span>
</div> </div>
{(canEnter || canReject) && ( {(canEnter || canReject || isDone) && (
<div className="mt-3 flex gap-2"> <div className="mt-3 flex gap-2">
{isDone && (
<button
type="button"
onClick={() => onExtraInfo(item)}
className="flex-1 rounded-lg border border-brand-600/40 py-2 text-xs font-bold text-brand-700 transition-all hover:bg-brand-50 active:scale-[0.98]"
>
{hasExtra ? '부가정보 수정' : '부가정보 입력'}
</button>
)}
{canReject && ( {canReject && (
<button <button
type="button" type="button"

View File

@ -10,19 +10,19 @@ interface WorkspaceTableProps {
busyId?: string | null busyId?: string | null
onEnter: (item: ListItem) => void onEnter: (item: ListItem) => void
onReject: (item: ListItem) => void onReject: (item: ListItem) => void
onExtraInfo: (item: ListItem) => void
} }
const HEAD = 'px-5 py-3.5 text-left text-[11px] font-bold uppercase tracking-wider text-neutral-60 whitespace-nowrap' const HEAD = 'px-5 py-3.5 text-left text-[11px] font-bold uppercase tracking-wider text-neutral-60 whitespace-nowrap'
const CELL = 'px-5 py-4 align-middle text-sm text-neutral-80' const CELL = 'px-5 py-4 align-middle text-sm text-neutral-80'
export function WorkspaceTable({ items, isLoading, busyId, onEnter, onReject }: WorkspaceTableProps) { export function WorkspaceTable({ items, isLoading, busyId, onEnter, onReject, onExtraInfo }: WorkspaceTableProps) {
return ( return (
<div className="w-full overflow-x-auto"> <div className="w-full overflow-x-auto">
<table className="w-full min-w-[900px] border-collapse"> <table className="w-full min-w-[900px] border-collapse">
<thead> <thead>
<tr className="border-b border-border bg-table-header"> <tr className="border-b border-border bg-table-header">
<th className={HEAD}></th> <th className={HEAD}></th>
<th className={HEAD}></th>
<th className={HEAD}></th> <th className={HEAD}></th>
<th className={HEAD}></th> <th className={HEAD}></th>
<th className={cn(HEAD, 'text-center')}></th> <th className={cn(HEAD, 'text-center')}></th>
@ -41,7 +41,7 @@ export function WorkspaceTable({ items, isLoading, busyId, onEnter, onReject }:
</StateRow> </StateRow>
) : ( ) : (
items.map((item) => ( items.map((item) => (
<Row key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} /> <Row key={item.session_id} item={item} busy={busyId === item.session_id} onEnter={onEnter} onReject={onReject} onExtraInfo={onExtraInfo} />
)) ))
)} )}
</tbody> </tbody>
@ -55,27 +55,26 @@ function Row({
busy, busy,
onEnter, onEnter,
onReject, onReject,
onExtraInfo,
}: { }: {
item: ListItem item: ListItem
busy: boolean busy: boolean
onEnter: (item: ListItem) => void onEnter: (item: ListItem) => void
onReject: (item: ListItem) => void onReject: (item: ListItem) => void
onExtraInfo: (item: ListItem) => void
}) { }) {
const meta = statusMeta(item.session_status) const meta = statusMeta(item.session_status)
const canEnter = !['미참여', '협상거부'].includes(item.session_status) const canEnter = !['미참여', '협상거부'].includes(item.session_status)
const canReject = ['협상생성', '협상중'].includes(item.session_status) const canReject = ['협상생성', '협상중'].includes(item.session_status)
const enterLabel = item.session_status === '협상완료' ? '결과 보기' : '협상 입장' const isDone = item.session_status === '협상완료'
const enterLabel = isDone ? '결과 보기' : '협상 입장'
const hasExtra = item.custom && Object.keys(item.custom).length > 0
return ( return (
<tr className="border-b border-border/60 transition-colors last:border-0 hover:bg-table-hover/70"> <tr className="border-b border-border/60 transition-colors last:border-0 hover:bg-table-hover/70">
<td className={cn(CELL, 'font-mono text-[13px] font-semibold text-neutral-90 whitespace-nowrap')}> <td className={cn(CELL, 'font-mono text-[13px] font-semibold text-neutral-90 whitespace-nowrap')}>
{item.qt_number || '-'} {item.qt_number || '-'}
</td> </td>
<td className={cn(CELL, 'whitespace-nowrap')}>
<span className="rounded-md bg-neutral-20 px-2 py-0.5 text-xs font-semibold text-neutral-70">
{item.qt_type || '-'}
</span>
</td>
<td className={CELL}> <td className={CELL}>
<p className="font-semibold text-neutral-90">{item.item_name || '-'}</p> <p className="font-semibold text-neutral-90">{item.item_name || '-'}</p>
<p className="mt-0.5 text-xs text-neutral-60"> <p className="mt-0.5 text-xs text-neutral-60">
@ -93,6 +92,15 @@ function Row({
<td className={cn(CELL, 'whitespace-nowrap text-neutral-70')}>{formatKstDateTime(item.qt_end_time)}</td> <td className={cn(CELL, 'whitespace-nowrap text-neutral-70')}>{formatKstDateTime(item.qt_end_time)}</td>
<td className={CELL}> <td className={CELL}>
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
{isDone && (
<button
type="button"
onClick={() => onExtraInfo(item)}
className="rounded-lg border border-brand-600/40 px-3 py-1.5 text-xs font-bold text-brand-700 transition-all hover:bg-brand-50 active:scale-[0.98]"
>
{hasExtra ? '부가정보 수정' : '부가정보 입력'}
</button>
)}
{canReject && ( {canReject && (
<button <button
type="button" type="button"
@ -124,7 +132,7 @@ function Row({
function StateRow({ children }: { children: ReactNode }) { function StateRow({ children }: { children: ReactNode }) {
return ( return (
<tr> <tr>
<td colSpan={7} className="py-20"> <td colSpan={6} className="py-20">
<div className="flex items-center justify-center">{children}</div> <div className="flex items-center justify-center">{children}</div>
</td> </td>
</tr> </tr>

View File

@ -2,7 +2,7 @@ import { useState } from 'react'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { Search } from 'lucide-react' import { Search } from 'lucide-react'
import { getApiErrorMessage, useMeQuery, useParticipateMutation, useRejectMutation } from '@/apis' import { getApiErrorMessage, useMeQuery, useParticipateMutation, useRejectMutation, useSaveExtraInfoMutation } from '@/apis'
import { cn, toast } from '@/lib' import { cn, toast } from '@/lib'
import { useList } from '@/features/list/hooks/useList' import { useList } from '@/features/list/hooks/useList'
import { useListStore } from '@/features/list/stores/useListStore' import { useListStore } from '@/features/list/stores/useListStore'
@ -12,6 +12,7 @@ import { WorkspaceTable } from '@/features/list/components/WorkspaceTable'
import { WorkspaceCards } from '@/features/list/components/WorkspaceCards' import { WorkspaceCards } from '@/features/list/components/WorkspaceCards'
import { Pagination } from '@/features/list/components/Pagination' import { Pagination } from '@/features/list/components/Pagination'
import { RejectPopup } from '@/features/list/components/RejectPopup' import { RejectPopup } from '@/features/list/components/RejectPopup'
import { ExtraInfoPopup } from '@/features/list/components/ExtraInfoPopup'
import type { ListItem } from '@/features/list/types' import type { ListItem } from '@/features/list/types'
// 상태별 거부 불가 안내 // 상태별 거부 불가 안내
@ -21,8 +22,6 @@ const REJECT_BLOCKED: Record<string, string> = {
: '협상완료 상태인 협상은 거부할 수 없습니다.', : '협상완료 상태인 협상은 거부할 수 없습니다.',
} }
const TYPE_OPTIONS = ['재견적', '재협상', '신규견적', '신규협상']
export function ListWorkspace() { export function ListWorkspace() {
const navigate = useNavigate() const navigate = useNavigate()
const { data: user } = useMeQuery() const { data: user } = useMeQuery()
@ -30,19 +29,19 @@ export function ListWorkspace() {
const { items, isLoading, totalPages, currentPage, setCurrentPage } = useList() const { items, isLoading, totalPages, currentPage, setCurrentPage } = useList()
const participate = useParticipateMutation() const participate = useParticipateMutation()
const reject = useRejectMutation() const reject = useRejectMutation()
const saveExtra = useSaveExtraInfoMutation()
const { selectedStatus, setStatus, selectedType, setType } = useListStore( const { selectedStatus, setStatus } = useListStore(
useShallow((s) => ({ useShallow((s) => ({
selectedStatus: s.selectedStatus, selectedStatus: s.selectedStatus,
setStatus: s.setStatus, setStatus: s.setStatus,
selectedType: s.selectedType,
setType: s.setType,
})), })),
) )
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [enteringId, setEnteringId] = useState<string | null>(null) const [enteringId, setEnteringId] = useState<string | null>(null)
const [rejectTarget, setRejectTarget] = useState<ListItem | null>(null) const [rejectTarget, setRejectTarget] = useState<ListItem | null>(null)
const [extraTarget, setExtraTarget] = useState<ListItem | null>(null)
const handleEnter = (item: ListItem) => { const handleEnter = (item: ListItem) => {
setEnteringId(item.session_id) setEnteringId(item.session_id)
@ -75,6 +74,17 @@ export function ListWorkspace() {
) )
} }
const handleExtraSubmit = (custom: Record<string, unknown>) => {
if (!extraTarget) return
saveExtra.mutate(
{ sessionId: extraTarget.session_id, request: { custom } },
{
onSuccess: () => toast.success('부가정보가 저장되었습니다.'),
onError: (error) => toast.error(getApiErrorMessage(error, '부가정보 저장에 실패했습니다.')),
},
)
}
return ( return (
<div className="space-y-5"> <div className="space-y-5">
{/* 환영 배너 */} {/* 환영 배너 */}
@ -97,18 +107,6 @@ export function ListWorkspace() {
<div className="flex flex-col gap-3 border-b border-border p-4 lg:flex-row lg:items-center lg:justify-between"> <div className="flex flex-col gap-3 border-b border-border p-4 lg:flex-row lg:items-center lg:justify-between">
<StatusTabs selected={selectedStatus} onSelect={setStatus} /> <StatusTabs selected={selectedStatus} onSelect={setStatus} />
<div className="flex w-full items-center gap-2 lg:w-auto"> <div className="flex w-full items-center gap-2 lg:w-auto">
<select
value={selectedType ?? ''}
onChange={(e) => setType(e.target.value || null)}
className="h-9 shrink-0 rounded-xl border border-border bg-white px-3 text-xs font-semibold text-neutral-80 outline-none transition-all focus:border-brand-600 focus:ring-1 focus:ring-brand-600"
>
<option value=""> </option>
{TYPE_OPTIONS.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
<div className="relative flex-1 lg:flex-none"> <div className="relative flex-1 lg:flex-none">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-neutral-50" /> <Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-neutral-50" />
<input <input
@ -132,6 +130,7 @@ export function ListWorkspace() {
busyId={participate.isPending ? enteringId : null} busyId={participate.isPending ? enteringId : null}
onEnter={handleEnter} onEnter={handleEnter}
onReject={handleReject} onReject={handleReject}
onExtraInfo={setExtraTarget}
/> />
</div> </div>
<div className="lg:hidden"> <div className="lg:hidden">
@ -141,6 +140,7 @@ export function ListWorkspace() {
busyId={participate.isPending ? enteringId : null} busyId={participate.isPending ? enteringId : null}
onEnter={handleEnter} onEnter={handleEnter}
onReject={handleReject} onReject={handleReject}
onExtraInfo={setExtraTarget}
/> />
</div> </div>
@ -152,6 +152,10 @@ export function ListWorkspace() {
{rejectTarget && ( {rejectTarget && (
<RejectPopup onClose={() => setRejectTarget(null)} onSubmit={handleRejectSubmit} /> <RejectPopup onClose={() => setRejectTarget(null)} onSubmit={handleRejectSubmit} />
)} )}
{extraTarget && (
<ExtraInfoPopup target={extraTarget} onClose={() => setExtraTarget(null)} onSubmit={handleExtraSubmit} />
)}
</div> </div>
) )
} }

View File

@ -1,17 +1,16 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { useSessionListQuery } from '@/apis' import { useSessionListQuery } from '@/apis'
import { useListStore } from '@/features/list/stores/useListStore' import { useListStore } from '@/features/list/stores/useListStore'
import { deadlineToOrder, statusLabelToCode, toListItem, typeLabelToCode } from '@/features/list/lib/adapter' import { deadlineToOrder, statusLabelToCode, toListItem } from '@/features/list/lib/adapter'
const PAGE_SIZE = 20 const PAGE_SIZE = 20
// 필터/정렬/페이지는 서버에 위임하고, 응답(정수 코드)을 화면용 라벨로 변환한다. // 필터/정렬/페이지는 서버에 위임하고, 응답(정수 코드)을 화면용 라벨로 변환한다.
export function useList() { export function useList() {
const { selectedType, selectedStatus, selectedDeadline, currentPage, setCurrentPage } = useListStore() const { selectedStatus, selectedDeadline, currentPage, setCurrentPage } = useListStore()
const query = useSessionListQuery({ const query = useSessionListQuery({
status: selectedStatus ? statusLabelToCode(selectedStatus) : undefined, status: selectedStatus ? statusLabelToCode(selectedStatus) : undefined,
qt_type: selectedType ? typeLabelToCode(selectedType) : undefined,
order: selectedDeadline ? deadlineToOrder(selectedDeadline) : undefined, order: selectedDeadline ? deadlineToOrder(selectedDeadline) : undefined,
page: currentPage, page: currentPage,
page_size: PAGE_SIZE, page_size: PAGE_SIZE,

View File

@ -37,5 +37,6 @@ export function toListItem(api: SessionListItem): ListItem {
item_name: api.item_name, item_name: api.item_name,
model_name: api.model_name, model_name: api.model_name,
maker_name: api.maker_name, maker_name: api.maker_name,
custom: api.custom ?? {},
} }
} }

View File

@ -8,4 +8,5 @@ export type ListItem = {
item_name: string item_name: string
model_name: string model_name: string
maker_name: string maker_name: string
custom: Record<string, unknown> // 협상완료 부가정보(입력값). 미입력이면 {}
} }

View File

@ -1,5 +1,6 @@
import { type ReactNode } from 'react' import { type ReactNode } from 'react'
import { Logo } from '@/components' import { Logo } from '@/components'
import { useMeQuery } from '@/apis'
import { cn } from '@/lib' import { cn } from '@/lib'
// 좌측 폭: list=반응형 비율 / chat=고정폭 단계 축소 // 좌측 폭: list=반응형 비율 / chat=고정폭 단계 축소
@ -43,11 +44,12 @@ export function MainLayout({
header, header,
children, children,
}: MainLayoutProps) { }: MainLayoutProps) {
const { data: user } = useMeQuery() // 회사 브랜딩(서비스명/로고) 주입
const panes = ( const panes = (
<> <>
<aside className={cn(styles.sidebar, SIDEBAR_WIDTH[sidebarWidth])}> <aside className={cn(styles.sidebar, SIDEBAR_WIDTH[sidebarWidth])}>
<div className={styles.logoHeader}> <div className={styles.logoHeader}>
<Logo size="sm" /> <Logo size="sm" serviceName={user?.branding?.service_name} logoUrl={user?.branding?.logo_url} />
{logoAction} {logoAction}
</div> </div>
{sidebar} {sidebar}

View File

@ -15,7 +15,7 @@ export function PortalHeader() {
return ( return (
<header className="sticky top-0 z-40 flex h-16 items-center justify-between border-b border-border bg-white/90 px-4 shadow-sm backdrop-blur sm:px-6"> <header className="sticky top-0 z-40 flex h-16 items-center justify-between border-b border-border bg-white/90 px-4 shadow-sm backdrop-blur sm:px-6">
<Logo size="md" /> <Logo size="md" serviceName={user?.branding?.service_name} logoUrl={user?.branding?.logo_url} />
<div className="flex items-center gap-2 sm:gap-3"> <div className="flex items-center gap-2 sm:gap-3">
<button <button

View File

@ -47,6 +47,8 @@ class companies(MainTableMixin, MAIN_BASE):
website_url = Column(String(255), nullable=True) website_url = Column(String(255), nullable=True)
industry = Column(SmallInteger, nullable=True) # 업종 코드 (스키마 SMALLINT) industry = Column(SmallInteger, nullable=True) # 업종 코드 (스키마 SMALLINT)
status = Column(SmallInteger, nullable=False, default=CompanyStatus.ACTIVE.value) # CompanyStatus status = Column(SmallInteger, nullable=False, default=CompanyStatus.ACTIVE.value) # CompanyStatus
# 회사별 커스터마이징 설정. branding(CI)/labels(용어)/features(동작)/item_fields·supplier_fields(커스텀필드 정의)
settings = Column(JSONB, nullable=True)
class users(MainTableMixin, MAIN_BASE): class users(MainTableMixin, MAIN_BASE):
@ -109,6 +111,8 @@ class items(MainTableMixin, MAIN_BASE):
delivery_type = Column(SmallInteger, nullable=True) # 배송 유형 코드 delivery_type = Column(SmallInteger, nullable=True) # 배송 유형 코드
vat_yn = Column(Boolean, nullable=True) vat_yn = Column(Boolean, nullable=True)
delivery_fee_yn = Column(Boolean, nullable=True) delivery_fee_yn = Column(Boolean, nullable=True)
# 회사 커스텀필드 값. 정의(키/라벨/타입)는 companies.settings.item_fields, 여기는 {key: value}만
custom = Column(JSONB, nullable=True)
class item_internet_lowest_prices(MainTableMixin, MAIN_BASE): class item_internet_lowest_prices(MainTableMixin, MAIN_BASE):
@ -145,6 +149,8 @@ class suppliers(MainTableMixin, MAIN_BASE):
manager_email = Column(String(255), nullable=True) manager_email = Column(String(255), nullable=True)
manager_contact_number = Column(String(20), nullable=True) # ERD 오타(manger) 교정 manager_contact_number = Column(String(20), nullable=True) # ERD 오타(manger) 교정
total_revenue = Column(BigInteger, nullable=True) # 총매출액 total_revenue = Column(BigInteger, nullable=True) # 총매출액
# 회사 커스텀필드 값. 정의는 companies.settings.supplier_fields, 여기는 {key: value}만
custom = Column(JSONB, nullable=True)
class supplier_users(MainTableMixin, MAIN_BASE): class supplier_users(MainTableMixin, MAIN_BASE):
@ -316,6 +322,7 @@ class sessions(MainTableMixin, MAIN_BASE):
reject_price = Column(BigInteger, nullable=True) reject_price = Column(BigInteger, nullable=True)
reject_delivery_type = Column(SmallInteger, nullable=True) # DeliveryType 코드 reject_delivery_type = Column(SmallInteger, nullable=True) # DeliveryType 코드
email_sent_at = Column(DateTime(timezone=True), nullable=True) # 협상 초청 메일 발송 시각(NULL=미발송) email_sent_at = Column(DateTime(timezone=True), nullable=True) # 협상 초청 메일 발송 시각(NULL=미발송)
custom = Column(JSONB, nullable=True) # 협상완료 부가정보 값 {key: value} (정의는 companies.settings.session_fields)
class chats(MainTableMixin, MAIN_BASE): class chats(MainTableMixin, MAIN_BASE):

View File

@ -5,7 +5,7 @@ from sqlalchemy import select, func, and_, or_, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import items, users from common.database.model.models import items, users, supplier_items, suppliers
from common.enums import ErrorType from common.enums import ErrorType
from common.logger import LOG from common.logger import LOG
from common.utils.gtime import GTime from common.utils.gtime import GTime
@ -49,6 +49,10 @@ class IItemCRUD(ABC):
async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]: async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]:
pass pass
@abstractmethod
async def supplier_name_map(self, cdb: AsyncSession, item_ids) -> Tuple[ErrorType, dict]:
pass
class ItemCRUD(IItemCRUD): class ItemCRUD(IItemCRUD):
async def search( async def search(
@ -184,3 +188,29 @@ class ItemCRUD(IItemCRUD):
except Exception as ex: except Exception as ex:
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, {} return ErrorType.DB_RUN_FAILED, {}
async def supplier_name_map(self, cdb: AsyncSession, item_ids) -> Tuple[ErrorType, dict]:
"""item_id 목록 → {item_id: [공급사명...]}. 상품 목록 '공급사' 컬럼용(supplier_items→suppliers 배치 조인)."""
try:
if not item_ids:
return ErrorType.SUCCESS, {}
query = (
select(supplier_items.item_id, suppliers.name)
.join(suppliers, suppliers.supplier_id == supplier_items.supplier_id)
.where(
supplier_items.item_id.in_(item_ids),
supplier_items.deleted == False, # noqa: E712
suppliers.deleted == False, # noqa: E712
)
.order_by(suppliers.name)
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
if err_type != ErrorType.SUCCESS:
return err_type, {}
out: dict = {}
for item_id, name in rows:
out.setdefault(item_id, []).append(name)
return ErrorType.SUCCESS, out
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, {}

View File

@ -2,6 +2,7 @@ from abc import ABC, abstractmethod
from typing import Tuple from typing import Tuple
from sqlalchemy import select, func, and_, case from sqlalchemy import select, func, and_, case
from sqlalchemy.orm import aliased
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
@ -54,6 +55,14 @@ class IStatisticsCRUD(ABC):
async def regen_avg_round(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, float]: async def regen_avg_round(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, float]:
pass pass
@abstractmethod
async def markup_suppression(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, float]:
pass
@abstractmethod
async def markup_suppression_monthly(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
pass
@abstractmethod @abstractmethod
async def card_usage(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]: async def card_usage(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
pass pass
@ -187,6 +196,87 @@ class StatisticsCRUD(IStatisticsCRUD):
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, 0.0 return ErrorType.DB_RUN_FAILED, 0.0
async def markup_suppression(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, float]:
# 인상억제율(재협상 전용): 같은 견적번호(qt_number)의 직전 라운드 투찰가 대비 이번 라운드 투찰가가
# 얼마나 안 올랐나 = avg((직전투찰 이번투찰) / 직전투찰). 양수=인하(억제 성공), 음수=인상 허용.
# 직전·이번 둘 다 유효 투찰(bid_price)이 있는 재협상 쌍만 대상(직전이 개찰/거부면 비교 불가 → 제외).
# 새 컬럼 없이 sessions.qt_number+qt_round+bid_price 로만 파생.
try:
prev = aliased(sessions)
stmt = (
select(func.avg((prev.bid_price - sessions.bid_price) * 1.0 / prev.bid_price))
.select_from(sessions)
.join(
prev,
and_(
prev.qt_number == sessions.qt_number,
prev.item_id == sessions.item_id,
prev.supplier_id == sessions.supplier_id,
prev.qt_round == sessions.qt_round - 1,
prev.bid_price.isnot(None),
prev.bid_price > 0,
prev.deleted == False, # noqa: E712
),
)
.join(quotations, quotations.qt_id == sessions.quotation_id)
.where(
and_(
*_company_scope(company_id, owner),
sessions.bid_price.isnot(None),
sessions.qt_round >= 2,
sessions.deleted == False, # noqa: E712
quotations.updated_at >= since,
)
)
)
err, rows = await DB_SESSION_MNG.execute(cdb, stmt)
if err != ErrorType.SUCCESS:
return err, 0.0
val = rows[0] if rows else None
return ErrorType.SUCCESS, float(val) if val is not None else 0.0
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, 0.0
async def markup_suppression_monthly(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
# 월별 인상억제율: 이번 라운드 마감월(quotations.updated_at)별 avg((직전투찰 이번투찰)/직전투찰).
try:
prev = aliased(sessions)
month = func.to_char(quotations.updated_at, "YYYY-MM")
stmt = (
select(month.label("m"), func.avg((prev.bid_price - sessions.bid_price) * 1.0 / prev.bid_price))
.select_from(sessions)
.join(
prev,
and_(
prev.qt_number == sessions.qt_number,
prev.item_id == sessions.item_id,
prev.supplier_id == sessions.supplier_id,
prev.qt_round == sessions.qt_round - 1,
prev.bid_price.isnot(None),
prev.bid_price > 0,
prev.deleted == False, # noqa: E712
),
)
.join(quotations, quotations.qt_id == sessions.quotation_id)
.where(
and_(
*_company_scope(company_id, owner),
sessions.bid_price.isnot(None),
sessions.qt_round >= 2,
sessions.deleted == False, # noqa: E712
quotations.updated_at >= since,
)
)
.group_by(month)
.order_by(month)
)
err, rows = await DB_SESSION_MNG.execute(cdb, stmt)
return (err, list(rows) if err == ErrorType.SUCCESS else [])
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, []
async def card_usage(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]: async def card_usage(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]:
# 카드 유형별 사용 빈도: card_used_yn=True 채팅을 card_type 별 집계(협상형 견적에서만 채팅 생성). # 카드 유형별 사용 빈도: card_used_yn=True 채팅을 card_type 별 집계(협상형 견적에서만 채팅 생성).
try: try:

View File

@ -5,7 +5,7 @@ from sqlalchemy import select, func, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import supplier_items, items from common.database.model.models import supplier_items, items, suppliers
from common.enums import ErrorType from common.enums import ErrorType
from common.logger import LOG from common.logger import LOG
from common.utils.gtime import GTime from common.utils.gtime import GTime
@ -48,7 +48,8 @@ class ISupplierItemCRUD(ABC):
class SupplierItemCRUD(ISupplierItemCRUD): class SupplierItemCRUD(ISupplierItemCRUD):
async def list_by_supplier(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, list]: async def list_by_supplier(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, list]:
# 협력사 상세용: 매핑 + 상품명/코드 조인. Row(supplier_item_id, item_id, name, code, supply_type) # 협력사 상세용: 매핑 + 상품명/코드/카테고리/제조사 조인.
# Row(supplier_item_id, item_id, name, code, supply_type, category, manufacturer)
try: try:
query = ( query = (
select( select(
@ -57,6 +58,8 @@ class SupplierItemCRUD(ISupplierItemCRUD):
items.name, items.name,
items.code, items.code,
supplier_items.supply_type, supplier_items.supply_type,
items.category,
items.manufacturer,
) )
.join(items, items.item_id == supplier_items.item_id) .join(items, items.item_id == supplier_items.item_id)
.where( .where(
@ -75,11 +78,22 @@ class SupplierItemCRUD(ISupplierItemCRUD):
return ErrorType.DB_RUN_FAILED, [] return ErrorType.DB_RUN_FAILED, []
async def list_by_item(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, list]: async def list_by_item(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, list]:
# 견적생성 모달용: 이 상품을 취급하는 협력사별 공급유형. Row(supplier_id, supply_type) # 견적생성 모달·상품 상세 공용: 이 상품을 취급하는 협력사별 공급유형.
# Row(supplier_id, supply_type, name, supplier_item_id)
try: try:
query = select(supplier_items.supplier_id, supplier_items.supply_type).where( query = (
supplier_items.item_id == item_id, select(
supplier_items.deleted == False, # noqa: E712 supplier_items.supplier_id,
supplier_items.supply_type,
suppliers.name,
supplier_items.supplier_item_id,
)
.join(suppliers, suppliers.supplier_id == supplier_items.supplier_id)
.where(
supplier_items.item_id == item_id,
supplier_items.deleted == False, # noqa: E712
suppliers.deleted == False, # noqa: E712
)
) )
err_type, rows = await DB_SESSION_MNG.execute(cdb, query) err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
if err_type != ErrorType.SUCCESS: if err_type != ErrorType.SUCCESS:

View File

@ -35,6 +35,10 @@ class IUserCRUD(ABC):
async def get_company(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, companies]: async def get_company(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, companies]:
pass pass
@abstractmethod
async def update_company_settings(self, cdb: AsyncSession, company_id, settings: dict) -> ErrorType:
pass
@abstractmethod @abstractmethod
async def list_by_company(self, cdb: AsyncSession, company_id, search, skip, limit) -> Tuple[ErrorType, list, int]: async def list_by_company(self, cdb: AsyncSession, company_id, search, skip, limit) -> Tuple[ErrorType, list, int]:
pass pass
@ -103,6 +107,18 @@ class UserCRUD(IUserCRUD):
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None return ErrorType.DB_RUN_FAILED, None
async def update_company_settings(self, cdb: AsyncSession, company_id, settings: dict) -> ErrorType:
try:
query = (
update(companies)
.where(companies.company_id == company_id, companies.deleted == False) # noqa: E712
.values(settings=settings, 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 list_by_company( async def list_by_company(
self, cdb: AsyncSession, company_id, search: Optional[str], skip: int, limit: int self, cdb: AsyncSession, company_id, search: Optional[str], skip: int, limit: int
) -> Tuple[ErrorType, list, int]: ) -> Tuple[ErrorType, list, int]:

View File

@ -12,6 +12,7 @@ from config.server_configs import web_server_config
from scheduler import shutdown_scheduler, start_scheduler from scheduler import shutdown_scheduler, start_scheduler
import router.v1.auth.account import router.v1.auth.account
import router.v1.company.user import router.v1.company.user
import router.v1.company.settings
import router.v1.item.item import router.v1.item.item
import router.v1.supplier.supplier import router.v1.supplier.supplier
import router.v1.supplier_item.supplier_item import router.v1.supplier_item.supplier_item
@ -68,6 +69,7 @@ async def healthz():
# 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.<domain>.<file> 를 import 후 include. # 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.<domain>.<file> 를 import 후 include.
app.include_router(router.v1.auth.account.router) app.include_router(router.v1.auth.account.router)
app.include_router(router.v1.company.user.router) app.include_router(router.v1.company.user.router)
app.include_router(router.v1.company.settings.router)
app.include_router(router.v1.item.item.router) app.include_router(router.v1.item.item.router)
app.include_router(router.v1.supplier.supplier.router) app.include_router(router.v1.supplier.supplier.router)
app.include_router(router.v1.supplier_item.supplier_item.router) app.include_router(router.v1.supplier_item.supplier_item.router)

View File

@ -56,3 +56,16 @@ class Res_CompanyUserList(Res_PageProtocol):
class Res_DeleteCompanyUser(Res_WebPacketProtocol): class Res_DeleteCompanyUser(Res_WebPacketProtocol):
pass pass
class CompanySettingsProtocol(WebPacketProtocol):
pass
class Req_UpdateCompanySettings(CompanySettingsProtocol):
# settings 전체 치환. 서브키: branding/labels/features/item_fields/supplier_fields/session_fields
settings: dict = {}
class Res_CompanySettings(Res_WebPacketProtocol):
settings: Optional[dict] = None

View File

@ -0,0 +1,21 @@
from fastapi import APIRouter, Depends
from common.models.gmodel import UserInfo
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse, RequireOwner
from services.company_settings_service import CompanySettingsService
from .protocol import Req_UpdateCompanySettings, Res_CompanySettings
# 회사별 커스터마이징 설정. 조회=로그인 유저 전원(앱 부팅 시 브랜딩/라벨 로드), 수정=최고관리자(OWNER) 전용.
router = APIRouter(prefix="/v1/company/settings", tags=["CompanySettings"], responses={404: {"description": "Not found"}})
@router.get(path="", response_model=Res_CompanySettings, summary="회사 커스터마이징 설정 조회")
async def get_settings(service: CompanySettingsService = Depends(), user: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.get_settings(user.company_id))
@router.put(path="/update", response_model=Res_CompanySettings, summary="회사 커스터마이징 설정 수정(최고관리자)")
async def update_settings(
req: Req_UpdateCompanySettings, service: CompanySettingsService = Depends(), owner: UserInfo = Depends(RequireOwner)
):
return RemoveNoneResponse(await service.update_settings(owner.company_id, req))

View File

@ -33,6 +33,7 @@ class Req_CreateItem(ItemProtocol):
delivery_type: Optional[int] = None delivery_type: Optional[int] = None
vat_yn: Optional[bool] = None vat_yn: Optional[bool] = None
delivery_fee_yn: Optional[bool] = None delivery_fee_yn: Optional[bool] = None
custom: Optional[dict] = None # 회사 커스텀필드 값 {key: value} (정의는 companies.settings.item_fields)
class Req_UpdateItem(ItemProtocol): class Req_UpdateItem(ItemProtocol):
@ -56,6 +57,7 @@ class Req_UpdateItem(ItemProtocol):
delivery_type: Optional[int] = None delivery_type: Optional[int] = None
vat_yn: Optional[bool] = None vat_yn: Optional[bool] = None
delivery_fee_yn: Optional[bool] = None delivery_fee_yn: Optional[bool] = None
custom: Optional[dict] = None # 회사 커스텀필드 값 {key: value}
class ItemData(WebPacketProtocol): class ItemData(WebPacketProtocol):
@ -85,6 +87,8 @@ class ItemData(WebPacketProtocol):
delivery_type: Optional[DeliveryType] = None delivery_type: Optional[DeliveryType] = None
vat_yn: Optional[bool] = None vat_yn: Optional[bool] = None
delivery_fee_yn: Optional[bool] = None delivery_fee_yn: Optional[bool] = None
custom: Optional[dict] = None # 회사 커스텀필드 값 {key: value}
supplier_names: list[str] = [] # 이 상품을 취급(계약)하는 공급사명 목록(목록 컬럼용, 배치 조인)
created_at: Optional[datetime] = None created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None updated_at: Optional[datetime] = None

View File

@ -111,6 +111,7 @@ class SessionData(WebPacketProtocol):
reject_price: Optional[int] = None reject_price: Optional[int] = None
reject_delivery_type: Optional[DeliveryType] = None reject_delivery_type: Optional[DeliveryType] = None
email_sent_at: Optional[datetime] = None # 협상 초청 메일 발송 시각(None=미발송). 프론트 발송배지/재발송 판단 email_sent_at: Optional[datetime] = None # 협상 초청 메일 발송 시각(None=미발송). 프론트 발송배지/재발송 판단
custom: Optional[dict] = None # 협상완료 부가정보 값 {key: value} (공급사가 타결 후 입력, 정의는 companies.settings.session_fields)
url: str = "" # 세션 chat 실행 URL(공급사 협상 프론트). DB 미저장 — session_id 로 구성 url: str = "" # 세션 chat 실행 URL(공급사 협상 프론트). DB 미저장 — session_id 로 구성

View File

@ -15,12 +15,18 @@ class StatKpi(WebPacketProtocol):
savings_delta_mom: int = 0 # 전월 대비 절감액 증감 savings_delta_mom: int = 0 # 전월 대비 절감액 증감
closed_count: int = 0 # 마감 견적 수(창) closed_count: int = 0 # 마감 견적 수(창)
regen_avg_round: float = 0.0 # 평균 재견적 라운드 regen_avg_round: float = 0.0 # 평균 재견적 라운드
markup_suppression_rate: float = 0.0 # 인상억제율(재협상: 직전 라운드 투찰가 대비 이번 투찰가 인하율, 파생)
class StatMonthPoint(WebPacketProtocol): class StatMonthPoint(WebPacketProtocol):
month: str # 'YYYY-MM' month: str # 'YYYY-MM'
savings: int = 0 savings: int = 0
rate: float = 0.0 rate: float = 0.0 # 절감률
class StatMarkupPoint(WebPacketProtocol):
month: str # 'YYYY-MM'
rate: float = 0.0 # 인상억제율(직전 라운드 투찰가 대비 인하율)
class StatOutcome(WebPacketProtocol): class StatOutcome(WebPacketProtocol):
@ -60,6 +66,7 @@ class StatCardUsage(WebPacketProtocol):
class StatScope(WebPacketProtocol): class StatScope(WebPacketProtocol):
kpi: StatKpi = Field(default_factory=StatKpi) kpi: StatKpi = Field(default_factory=StatKpi)
trend: list[StatMonthPoint] = [] trend: list[StatMonthPoint] = []
markup_trend: list[StatMarkupPoint] = [] # 월별 인상억제율(재협상)
outcome: StatOutcome = Field(default_factory=StatOutcome) outcome: StatOutcome = Field(default_factory=StatOutcome)
participation: StatParticipation = Field(default_factory=StatParticipation) participation: StatParticipation = Field(default_factory=StatParticipation)
type_split: list[StatTypeRow] = [] type_split: list[StatTypeRow] = []

View File

@ -18,6 +18,7 @@ class Req_CreateSupplier(SupplierProtocol):
manager_email: Optional[str] = None manager_email: Optional[str] = None
manager_contact_number: Optional[str] = None manager_contact_number: Optional[str] = None
total_revenue: Optional[int] = None # 총매출액(원) total_revenue: Optional[int] = None # 총매출액(원)
custom: Optional[dict] = None # 회사 커스텀필드 값 {key: value} (정의는 companies.settings.supplier_fields)
class Req_UpdateSupplier(SupplierProtocol): class Req_UpdateSupplier(SupplierProtocol):
@ -27,6 +28,7 @@ class Req_UpdateSupplier(SupplierProtocol):
manager_email: Optional[str] = None manager_email: Optional[str] = None
manager_contact_number: Optional[str] = None manager_contact_number: Optional[str] = None
total_revenue: Optional[int] = None total_revenue: Optional[int] = None
custom: Optional[dict] = None # 회사 커스텀필드 값 {key: value}
class SupplierData(WebPacketProtocol): class SupplierData(WebPacketProtocol):
@ -42,6 +44,7 @@ class SupplierData(WebPacketProtocol):
manager_email: Optional[str] = None manager_email: Optional[str] = None
manager_contact_number: Optional[str] = None manager_contact_number: Optional[str] = None
total_revenue: Optional[int] = None # 총매출액(원) total_revenue: Optional[int] = None # 총매출액(원)
custom: Optional[dict] = None # 회사 커스텀필드 값 {key: value}
account_login_id: Optional[str] = None # 채팅(협상) 계정 로그인 ID. None=미발급 account_login_id: Optional[str] = None # 채팅(협상) 계정 로그인 ID. None=미발급
account_status: Optional[int] = None # SupplierUserStatus. None=미발급 account_status: Optional[int] = None # SupplierUserStatus. None=미발급
created_at: Optional[datetime] = None created_at: Optional[datetime] = None

View File

@ -33,6 +33,8 @@ class SupplierItemData(WebPacketProtocol):
item_name: str item_name: str
item_code: Optional[str] = None item_code: Optional[str] = None
supply_type: int supply_type: int
item_category: Optional[str] = None
item_manufacturer: Optional[str] = None
created_at: Optional[datetime] = None created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None updated_at: Optional[datetime] = None
@ -40,6 +42,8 @@ class SupplierItemData(WebPacketProtocol):
class ItemSupplyType(WebPacketProtocol): class ItemSupplyType(WebPacketProtocol):
supplier_id: uuid.UUID supplier_id: uuid.UUID
supply_type: int supply_type: int
supplier_name: Optional[str] = None
supplier_item_id: Optional[uuid.UUID] = None # 상품측 매핑 편집(수정/삭제)용
class Res_SupplierItem(Res_WebPacketProtocol): class Res_SupplierItem(Res_WebPacketProtocol):

View File

@ -0,0 +1,45 @@
import uuid
from fastapi import Depends
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import companies
from common.enums import DBWRType, ErrorType
from crud.user_crud import IUserCRUD, UserCRUD
from router.v1.company.protocol import Req_UpdateCompanySettings, Res_CompanySettings
class CompanySettingsService:
"""회사별 커스터마이징 설정(companies.settings JSONB) 조회/수정.
- 조회는 로그인 유저 전원(브랜딩/라벨을 부팅 로드), 수정은 라우터에서 RequireOwner 게이트.
- company_id 토큰값만 쓴다 남의 회사 설정 접근 불가.
"""
def __init__(self, user_crud: IUserCRUD = Depends(UserCRUD)):
self.user_crud = user_crud
async def get_settings(self, company_id: str) -> Res_CompanySettings:
res = Res_CompanySettings()
err_type, company = await DB_SESSION_MNG.execute_lambda(
companies.DBType(),
DBWRType.DB_READ.value,
lambda s: self.user_crud.get_company(s, uuid.UUID(company_id)),
)
if err_type != ErrorType.SUCCESS or company is None:
res.result.SetResult(err_type if err_type != ErrorType.SUCCESS else ErrorType.ACCOUNT_NOT_FOUND)
return res
res.settings = company.settings
return res
async def update_settings(self, company_id: str, req: Req_UpdateCompanySettings) -> Res_CompanySettings:
res = Res_CompanySettings()
err_type = await DB_SESSION_MNG.execute_lambda_run(
[companies.DBType()],
[lambda s: self.user_crud.update_company_settings(s, uuid.UUID(company_id), req.settings)],
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
res.settings = req.settings
return res

View File

@ -67,6 +67,16 @@ class ItemService:
if nm_err == ErrorType.SUCCESS: if nm_err == ErrorType.SUCCESS:
for d in res.items: for d in res.items:
d.creator_name = name_map.get(d.user_id) d.creator_name = name_map.get(d.user_id)
# 공급사명 배치 조인 — 페이지 상품의 item_id를 모아 IN 쿼리 1회로 {item_id:[공급사명]} 맵을 만들어 매핑.
item_ids = [r.item_id for r in rows]
if item_ids:
sn_err, supplier_map = await DB_SESSION_MNG.execute_lambda(
items.DBType(), DBWRType.DB_READ.value,
lambda s: self.item_crud.supplier_name_map(s, item_ids),
)
if sn_err == ErrorType.SUCCESS:
for d in res.items:
d.supplier_names = supplier_map.get(d.item_id, [])
res.total = total res.total = total
return res return res

View File

@ -877,6 +877,7 @@ class QuotationService:
reject_price=r.reject_price, reject_price=r.reject_price,
reject_delivery_type=r.reject_delivery_type, reject_delivery_type=r.reject_delivery_type,
email_sent_at=r.email_sent_at, email_sent_at=r.email_sent_at,
custom=r.custom,
url=self._session_chat_url(r.session_id), url=self._session_chat_url(r.session_id),
) )
for r in rows for r in rows

View File

@ -12,6 +12,7 @@ from router.v1.statistics.protocol import (
StatScope, StatScope,
StatKpi, StatKpi,
StatMonthPoint, StatMonthPoint,
StatMarkupPoint,
StatOutcome, StatOutcome,
StatParticipation, StatParticipation,
StatTypeRow, StatTypeRow,
@ -51,19 +52,22 @@ class StatisticsService:
type_rows = await self._read(lambda s: self.stat_crud.type_counts(s, company_uuid, owner_uuid, since)) type_rows = await self._read(lambda s: self.stat_crud.type_counts(s, company_uuid, owner_uuid, since))
part_rows = await self._read(lambda s: self.stat_crud.participation_counts(s, company_uuid, owner_uuid, since)) part_rows = await self._read(lambda s: self.stat_crud.participation_counts(s, company_uuid, owner_uuid, since))
regen = await self._read_scalar(lambda s: self.stat_crud.regen_avg_round(s, company_uuid, owner_uuid, since)) regen = await self._read_scalar(lambda s: self.stat_crud.regen_avg_round(s, company_uuid, owner_uuid, since))
markup = await self._read_scalar(lambda s: self.stat_crud.markup_suppression(s, company_uuid, owner_uuid, since))
markup_rows = await self._read(lambda s: self.stat_crud.markup_suppression_monthly(s, company_uuid, owner_uuid, since))
card_rows = await self._read(lambda s: self.stat_crud.card_usage(s, company_uuid, owner_uuid, since)) card_rows = await self._read(lambda s: self.stat_crud.card_usage(s, company_uuid, owner_uuid, since))
scope.trend = self._trend(win_rows, labels) scope.trend = self._trend(win_rows, labels)
scope.markup_trend = [StatMarkupPoint(month=r[0], rate=float(r[1] or 0.0)) for r in markup_rows]
scope.categories = self._categories(win_rows) scope.categories = self._categories(win_rows)
scope.outcome = self._outcome(outcome_rows) scope.outcome = self._outcome(outcome_rows)
scope.participation = self._participation(part_rows) scope.participation = self._participation(part_rows)
scope.type_split = self._type_split(type_rows, win_rows) scope.type_split = self._type_split(type_rows, win_rows)
scope.cards = self._cards(card_rows) scope.cards = self._cards(card_rows)
scope.kpi = self._kpi(win_rows, scope.trend, scope.outcome, regen) scope.kpi = self._kpi(win_rows, scope.trend, scope.outcome, regen, markup)
return scope return scope
# ── 파생 계산 ─────────────────────────────────────────────── # ── 파생 계산 ───────────────────────────────────────────────
def _kpi(self, win_rows, trend, outcome, regen) -> StatKpi: def _kpi(self, win_rows, trend, outcome, regen, markup) -> StatKpi:
k = StatKpi() k = StatKpi()
total_saving = sum(int(r.target_price) - int(r.bid_price) for r in win_rows) total_saving = sum(int(r.target_price) - int(r.bid_price) for r in win_rows)
total_target = sum(int(r.target_price) for r in win_rows) total_target = sum(int(r.target_price) for r in win_rows)
@ -75,6 +79,7 @@ class StatisticsService:
k.closed_count = closed k.closed_count = closed
k.award_rate = (outcome.awarded / closed) if closed else 0.0 k.award_rate = (outcome.awarded / closed) if closed else 0.0
k.regen_avg_round = round(regen, 2) k.regen_avg_round = round(regen, 2)
k.markup_suppression_rate = round(markup, 4) # 인상억제율(재협상 직전 라운드 투찰가 대비, 파생)
# 전월 대비: 마지막 두 달 절감액 차(창에 2개월 미만이면 0). # 전월 대비: 마지막 두 달 절감액 차(창에 2개월 미만이면 0).
k.savings_delta_mom = (trend[-1].savings - trend[-2].savings) if len(trend) >= 2 else 0 k.savings_delta_mom = (trend[-1].savings - trend[-2].savings) if len(trend) >= 2 else 0
return k return k

View File

@ -85,10 +85,11 @@ class SupplierItemService:
if err_type != ErrorType.SUCCESS: if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type) res.result.SetResult(err_type)
return res return res
# Row(supplier_item_id, item_id, name, code, supply_type) # Row(supplier_item_id, item_id, name, code, supply_type, category, manufacturer)
res.supplier_items = [ res.supplier_items = [
SupplierItemData( SupplierItemData(
supplier_item_id=r[0], item_id=r[1], item_name=r[2], item_code=r[3], supply_type=r[4] supplier_item_id=r[0], item_id=r[1], item_name=r[2], item_code=r[3], supply_type=r[4],
item_category=r[5], item_manufacturer=r[6],
) )
for r in rows for r in rows
] ]
@ -109,8 +110,10 @@ class SupplierItemService:
if err_type != ErrorType.SUCCESS: if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type) res.result.SetResult(err_type)
return res return res
# Row(supplier_id, supply_type) # Row(supplier_id, supply_type, name, supplier_item_id)
res.suppliers = [ItemSupplyType(supplier_id=r[0], supply_type=r[1]) for r in rows] res.suppliers = [
ItemSupplyType(supplier_id=r[0], supply_type=r[1], supplier_name=r[2], supplier_item_id=r[3]) for r in rows
]
return res return res
async def create(self, company_id: str, req: Req_CreateSupplierItem) -> Res_SupplierItem: async def create(self, company_id: str, req: Req_CreateSupplierItem) -> Res_SupplierItem:

View File

@ -170,6 +170,7 @@ class SupplierService:
manager_email=req.manager_email, manager_email=req.manager_email,
manager_contact_number=req.manager_contact_number, manager_contact_number=req.manager_contact_number,
total_revenue=req.total_revenue, total_revenue=req.total_revenue,
custom=req.custom,
) )
err_type = await DB_SESSION_MNG.execute_lambda_run( err_type = await DB_SESSION_MNG.execute_lambda_run(
[suppliers.DBType()], [suppliers.DBType()],

View File

@ -0,0 +1,194 @@
/**
* 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,
ReqUpdateCompanySettings,
ResCompanySettings
} from '.././model';
import { customFetch } from '../../mutator/custom-fetch';
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
/**
* @summary
*/
export const getSettings = (
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
) => {
return customFetch<ResCompanySettings>(
{url: `/v1/company/settings`, method: 'GET', signal
},
options);
}
export const getGetSettingsQueryKey = () => {
return [
`/v1/company/settings`
] as const;
}
export const getGetSettingsQueryOptions = <TData = Awaited<ReturnType<typeof getSettings>>, TError = void>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSettings>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetSettingsQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof getSettings>>> = ({ signal }) => getSettings(requestOptions, signal);
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getSettings>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type GetSettingsQueryResult = NonNullable<Awaited<ReturnType<typeof getSettings>>>
export type GetSettingsQueryError = void
export function useGetSettings<TData = Awaited<ReturnType<typeof getSettings>>, TError = void>(
options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSettings>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof getSettings>>,
TError,
Awaited<ReturnType<typeof getSettings>>
> , 'initialData'
>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetSettings<TData = Awaited<ReturnType<typeof getSettings>>, TError = void>(
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSettings>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof getSettings>>,
TError,
Awaited<ReturnType<typeof getSettings>>
> , 'initialData'
>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetSettings<TData = Awaited<ReturnType<typeof getSettings>>, TError = void>(
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSettings>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary
*/
export function useGetSettings<TData = Awaited<ReturnType<typeof getSettings>>, TError = void>(
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSettings>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getGetSettingsQueryOptions(options)
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey ;
return query;
}
/**
* @summary ()
*/
export const updateSettings = (
reqUpdateCompanySettings: ReqUpdateCompanySettings,
options?: SecondParameter<typeof customFetch>,) => {
return customFetch<ResCompanySettings>(
{url: `/v1/company/settings/update`, method: 'PUT',
headers: {'Content-Type': 'application/json', },
data: reqUpdateCompanySettings
},
options);
}
export const getUpdateSettingsMutationOptions = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateSettings>>, TError,{data: ReqUpdateCompanySettings}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof updateSettings>>, TError,{data: ReqUpdateCompanySettings}, TContext> => {
const mutationKey = ['updateSettings'];
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 updateSettings>>, {data: ReqUpdateCompanySettings}> = (props) => {
const {data} = props ?? {};
return updateSettings(data,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type UpdateSettingsMutationResult = NonNullable<Awaited<ReturnType<typeof updateSettings>>>
export type UpdateSettingsMutationBody = ReqUpdateCompanySettings
export type UpdateSettingsMutationError = void | HTTPValidationError
/**
* @summary ()
*/
export const useUpdateSettings = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateSettings>>, TError,{data: ReqUpdateCompanySettings}, TContext>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof updateSettings>>,
TError,
{data: ReqUpdateCompanySettings},
TContext
> => {
const mutationOptions = getUpdateSettingsMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}

View File

@ -55,6 +55,8 @@ export * from './itemDataCategory';
export * from './itemDataCode'; export * from './itemDataCode';
export * from './itemDataCreatedAt'; export * from './itemDataCreatedAt';
export * from './itemDataCreatorName'; export * from './itemDataCreatorName';
export * from './itemDataCustom';
export * from './itemDataCustomAnyOf';
export * from './itemDataDeliveryFeeYn'; export * from './itemDataDeliveryFeeYn';
export * from './itemDataDeliveryType'; export * from './itemDataDeliveryType';
export * from './itemDataImageUrl'; export * from './itemDataImageUrl';
@ -72,6 +74,8 @@ export * from './itemDataSpec';
export * from './itemDataUpdatedAt'; export * from './itemDataUpdatedAt';
export * from './itemDataVatYn'; export * from './itemDataVatYn';
export * from './itemSupplyType'; export * from './itemSupplyType';
export * from './itemSupplyTypeSupplierItemId';
export * from './itemSupplyTypeSupplierName';
export * from './listCardsParams'; export * from './listCardsParams';
export * from './listItemsParams'; export * from './listItemsParams';
export * from './listNotificationsParams'; export * from './listNotificationsParams';
@ -139,6 +143,8 @@ export * from './reqCreateCompanyUser';
export * from './reqCreateItem'; export * from './reqCreateItem';
export * from './reqCreateItemCategory'; export * from './reqCreateItemCategory';
export * from './reqCreateItemCode'; export * from './reqCreateItemCode';
export * from './reqCreateItemCustom';
export * from './reqCreateItemCustomAnyOf';
export * from './reqCreateItemDeliveryFeeYn'; export * from './reqCreateItemDeliveryFeeYn';
export * from './reqCreateItemDeliveryType'; export * from './reqCreateItemDeliveryType';
export * from './reqCreateItemImageUrl'; export * from './reqCreateItemImageUrl';
@ -168,6 +174,8 @@ export * from './reqCreateQuotationVersionId';
export * from './reqCreateSupplier'; export * from './reqCreateSupplier';
export * from './reqCreateSupplierAccount'; export * from './reqCreateSupplierAccount';
export * from './reqCreateSupplierCode'; export * from './reqCreateSupplierCode';
export * from './reqCreateSupplierCustom';
export * from './reqCreateSupplierCustomAnyOf';
export * from './reqCreateSupplierItem'; export * from './reqCreateSupplierItem';
export * from './reqCreateSupplierManagerContactNumber'; export * from './reqCreateSupplierManagerContactNumber';
export * from './reqCreateSupplierManagerEmail'; export * from './reqCreateSupplierManagerEmail';
@ -186,6 +194,8 @@ export * from './reqUpdateCardNumber';
export * from './reqUpdateCardScript'; export * from './reqUpdateCardScript';
export * from './reqUpdateCardStatus'; export * from './reqUpdateCardStatus';
export * from './reqUpdateCardUsageType'; export * from './reqUpdateCardUsageType';
export * from './reqUpdateCompanySettings';
export * from './reqUpdateCompanySettingsSettings';
export * from './reqUpdateCompanyUser'; export * from './reqUpdateCompanyUser';
export * from './reqUpdateCompanyUserContactNumber'; export * from './reqUpdateCompanyUserContactNumber';
export * from './reqUpdateCompanyUserEmail'; export * from './reqUpdateCompanyUserEmail';
@ -196,6 +206,8 @@ export * from './reqUpdateItem';
export * from './reqUpdateItemCategory'; export * from './reqUpdateItemCategory';
export * from './reqUpdateItemCategoryType'; export * from './reqUpdateItemCategoryType';
export * from './reqUpdateItemCode'; export * from './reqUpdateItemCode';
export * from './reqUpdateItemCustom';
export * from './reqUpdateItemCustomAnyOf';
export * from './reqUpdateItemDeliveryFeeYn'; export * from './reqUpdateItemDeliveryFeeYn';
export * from './reqUpdateItemDeliveryType'; export * from './reqUpdateItemDeliveryType';
export * from './reqUpdateItemImageUrl'; export * from './reqUpdateItemImageUrl';
@ -224,6 +236,8 @@ export * from './reqUpdateQuotationSettingTargetMarginRate';
export * from './reqUpdateSupplier'; export * from './reqUpdateSupplier';
export * from './reqUpdateSupplierAccountStatus'; export * from './reqUpdateSupplierAccountStatus';
export * from './reqUpdateSupplierCode'; export * from './reqUpdateSupplierCode';
export * from './reqUpdateSupplierCustom';
export * from './reqUpdateSupplierCustomAnyOf';
export * from './reqUpdateSupplierManagerContactNumber'; export * from './reqUpdateSupplierManagerContactNumber';
export * from './reqUpdateSupplierManagerEmail'; export * from './reqUpdateSupplierManagerEmail';
export * from './reqUpdateSupplierManagerName'; export * from './reqUpdateSupplierManagerName';
@ -239,6 +253,10 @@ export * from './resCardListMsg';
export * from './resCardMsg'; export * from './resCardMsg';
export * from './resCheckCodes'; export * from './resCheckCodes';
export * from './resCheckCodesMsg'; export * from './resCheckCodesMsg';
export * from './resCompanySettings';
export * from './resCompanySettingsMsg';
export * from './resCompanySettingsSettings';
export * from './resCompanySettingsSettingsAnyOf';
export * from './resCompanyUser'; export * from './resCompanyUser';
export * from './resCompanyUserList'; export * from './resCompanyUserList';
export * from './resCompanyUserListMsg'; export * from './resCompanyUserListMsg';
@ -361,6 +379,8 @@ export * from './sessionData';
export * from './sessionDataAnchoringPrice'; export * from './sessionDataAnchoringPrice';
export * from './sessionDataBidAt'; export * from './sessionDataBidAt';
export * from './sessionDataBidPrice'; export * from './sessionDataBidPrice';
export * from './sessionDataCustom';
export * from './sessionDataCustomAnyOf';
export * from './sessionDataEmailSentAt'; export * from './sessionDataEmailSentAt';
export * from './sessionDataRejectDeliveryType'; export * from './sessionDataRejectDeliveryType';
export * from './sessionDataRejectPrice'; export * from './sessionDataRejectPrice';
@ -369,6 +389,7 @@ export * from './sessionStatus';
export * from './statCardUsage'; export * from './statCardUsage';
export * from './statCategory'; export * from './statCategory';
export * from './statKpi'; export * from './statKpi';
export * from './statMarkupPoint';
export * from './statMonthPoint'; export * from './statMonthPoint';
export * from './statOutcome'; export * from './statOutcome';
export * from './statParticipation'; export * from './statParticipation';
@ -382,6 +403,8 @@ export * from './supplierDataAccountStatus';
export * from './supplierDataCode'; export * from './supplierDataCode';
export * from './supplierDataCreatedAt'; export * from './supplierDataCreatedAt';
export * from './supplierDataCreatorName'; export * from './supplierDataCreatorName';
export * from './supplierDataCustom';
export * from './supplierDataCustomAnyOf';
export * from './supplierDataManagerContactNumber'; export * from './supplierDataManagerContactNumber';
export * from './supplierDataManagerEmail'; export * from './supplierDataManagerEmail';
export * from './supplierDataManagerName'; export * from './supplierDataManagerName';
@ -389,7 +412,9 @@ export * from './supplierDataTotalRevenue';
export * from './supplierDataUpdatedAt'; export * from './supplierDataUpdatedAt';
export * from './supplierItemData'; export * from './supplierItemData';
export * from './supplierItemDataCreatedAt'; export * from './supplierItemDataCreatedAt';
export * from './supplierItemDataItemCategory';
export * from './supplierItemDataItemCode'; export * from './supplierItemDataItemCode';
export * from './supplierItemDataItemManufacturer';
export * from './supplierItemDataUpdatedAt'; export * from './supplierItemDataUpdatedAt';
export * from './targetCandidate'; export * from './targetCandidate';
export * from './userRole'; export * from './userRole';

View File

@ -22,6 +22,7 @@ import type { ItemDataQuantityUnit } from './itemDataQuantityUnit';
import type { ItemDataDeliveryType } from './itemDataDeliveryType'; import type { ItemDataDeliveryType } from './itemDataDeliveryType';
import type { ItemDataVatYn } from './itemDataVatYn'; import type { ItemDataVatYn } from './itemDataVatYn';
import type { ItemDataDeliveryFeeYn } from './itemDataDeliveryFeeYn'; import type { ItemDataDeliveryFeeYn } from './itemDataDeliveryFeeYn';
import type { ItemDataCustom } from './itemDataCustom';
import type { ItemDataCreatedAt } from './itemDataCreatedAt'; import type { ItemDataCreatedAt } from './itemDataCreatedAt';
import type { ItemDataUpdatedAt } from './itemDataUpdatedAt'; import type { ItemDataUpdatedAt } from './itemDataUpdatedAt';
@ -50,6 +51,8 @@ export interface ItemData {
delivery_type?: ItemDataDeliveryType; delivery_type?: ItemDataDeliveryType;
vat_yn?: ItemDataVatYn; vat_yn?: ItemDataVatYn;
delivery_fee_yn?: ItemDataDeliveryFeeYn; delivery_fee_yn?: ItemDataDeliveryFeeYn;
custom?: ItemDataCustom;
supplier_names?: string[];
created_at?: ItemDataCreatedAt; created_at?: ItemDataCreatedAt;
updated_at?: ItemDataUpdatedAt; updated_at?: ItemDataUpdatedAt;
} }

View File

@ -0,0 +1,9 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { ItemDataCustomAnyOf } from './itemDataCustomAnyOf';
export type ItemDataCustom = ItemDataCustomAnyOf | 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 ItemDataCustomAnyOf = { [key: string]: unknown };

View File

@ -4,8 +4,12 @@
* Negodata Api Server * Negodata Api Server
* OpenAPI spec version: 0.1.0 * OpenAPI spec version: 0.1.0
*/ */
import type { ItemSupplyTypeSupplierName } from './itemSupplyTypeSupplierName';
import type { ItemSupplyTypeSupplierItemId } from './itemSupplyTypeSupplierItemId';
export interface ItemSupplyType { export interface ItemSupplyType {
supplier_id: string; supplier_id: string;
supply_type: number; supply_type: number;
supplier_name?: ItemSupplyTypeSupplierName;
supplier_item_id?: ItemSupplyTypeSupplierItemId;
} }

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 ItemSupplyTypeSupplierItemId = 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 ItemSupplyTypeSupplierName = string | null;

View File

@ -21,6 +21,7 @@ import type { ReqCreateItemQuantityUnit } from './reqCreateItemQuantityUnit';
import type { ReqCreateItemDeliveryType } from './reqCreateItemDeliveryType'; import type { ReqCreateItemDeliveryType } from './reqCreateItemDeliveryType';
import type { ReqCreateItemVatYn } from './reqCreateItemVatYn'; import type { ReqCreateItemVatYn } from './reqCreateItemVatYn';
import type { ReqCreateItemDeliveryFeeYn } from './reqCreateItemDeliveryFeeYn'; import type { ReqCreateItemDeliveryFeeYn } from './reqCreateItemDeliveryFeeYn';
import type { ReqCreateItemCustom } from './reqCreateItemCustom';
export interface ReqCreateItem { export interface ReqCreateItem {
name?: string; name?: string;
@ -43,4 +44,5 @@ export interface ReqCreateItem {
delivery_type?: ReqCreateItemDeliveryType; delivery_type?: ReqCreateItemDeliveryType;
vat_yn?: ReqCreateItemVatYn; vat_yn?: ReqCreateItemVatYn;
delivery_fee_yn?: ReqCreateItemDeliveryFeeYn; delivery_fee_yn?: ReqCreateItemDeliveryFeeYn;
custom?: ReqCreateItemCustom;
} }

View File

@ -0,0 +1,9 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { ReqCreateItemCustomAnyOf } from './reqCreateItemCustomAnyOf';
export type ReqCreateItemCustom = ReqCreateItemCustomAnyOf | 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 ReqCreateItemCustomAnyOf = { [key: string]: unknown };

View File

@ -9,6 +9,7 @@ import type { ReqCreateSupplierManagerName } from './reqCreateSupplierManagerNam
import type { ReqCreateSupplierManagerEmail } from './reqCreateSupplierManagerEmail'; import type { ReqCreateSupplierManagerEmail } from './reqCreateSupplierManagerEmail';
import type { ReqCreateSupplierManagerContactNumber } from './reqCreateSupplierManagerContactNumber'; import type { ReqCreateSupplierManagerContactNumber } from './reqCreateSupplierManagerContactNumber';
import type { ReqCreateSupplierTotalRevenue } from './reqCreateSupplierTotalRevenue'; import type { ReqCreateSupplierTotalRevenue } from './reqCreateSupplierTotalRevenue';
import type { ReqCreateSupplierCustom } from './reqCreateSupplierCustom';
export interface ReqCreateSupplier { export interface ReqCreateSupplier {
name?: string; name?: string;
@ -17,4 +18,5 @@ export interface ReqCreateSupplier {
manager_email?: ReqCreateSupplierManagerEmail; manager_email?: ReqCreateSupplierManagerEmail;
manager_contact_number?: ReqCreateSupplierManagerContactNumber; manager_contact_number?: ReqCreateSupplierManagerContactNumber;
total_revenue?: ReqCreateSupplierTotalRevenue; total_revenue?: ReqCreateSupplierTotalRevenue;
custom?: ReqCreateSupplierCustom;
} }

View File

@ -0,0 +1,9 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { ReqCreateSupplierCustomAnyOf } from './reqCreateSupplierCustomAnyOf';
export type ReqCreateSupplierCustom = ReqCreateSupplierCustomAnyOf | 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 ReqCreateSupplierCustomAnyOf = { [key: string]: unknown };

View File

@ -0,0 +1,11 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { ReqUpdateCompanySettingsSettings } from './reqUpdateCompanySettingsSettings';
export interface ReqUpdateCompanySettings {
settings?: ReqUpdateCompanySettingsSettings;
}

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 ReqUpdateCompanySettingsSettings = { [key: string]: unknown };

View File

@ -24,6 +24,7 @@ import type { ReqUpdateItemQuantityUnit } from './reqUpdateItemQuantityUnit';
import type { ReqUpdateItemDeliveryType } from './reqUpdateItemDeliveryType'; import type { ReqUpdateItemDeliveryType } from './reqUpdateItemDeliveryType';
import type { ReqUpdateItemVatYn } from './reqUpdateItemVatYn'; import type { ReqUpdateItemVatYn } from './reqUpdateItemVatYn';
import type { ReqUpdateItemDeliveryFeeYn } from './reqUpdateItemDeliveryFeeYn'; import type { ReqUpdateItemDeliveryFeeYn } from './reqUpdateItemDeliveryFeeYn';
import type { ReqUpdateItemCustom } from './reqUpdateItemCustom';
export interface ReqUpdateItem { export interface ReqUpdateItem {
name?: ReqUpdateItemName; name?: ReqUpdateItemName;
@ -46,4 +47,5 @@ export interface ReqUpdateItem {
delivery_type?: ReqUpdateItemDeliveryType; delivery_type?: ReqUpdateItemDeliveryType;
vat_yn?: ReqUpdateItemVatYn; vat_yn?: ReqUpdateItemVatYn;
delivery_fee_yn?: ReqUpdateItemDeliveryFeeYn; delivery_fee_yn?: ReqUpdateItemDeliveryFeeYn;
custom?: ReqUpdateItemCustom;
} }

View File

@ -0,0 +1,9 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { ReqUpdateItemCustomAnyOf } from './reqUpdateItemCustomAnyOf';
export type ReqUpdateItemCustom = ReqUpdateItemCustomAnyOf | 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 ReqUpdateItemCustomAnyOf = { [key: string]: unknown };

View File

@ -10,6 +10,7 @@ import type { ReqUpdateSupplierManagerName } from './reqUpdateSupplierManagerNam
import type { ReqUpdateSupplierManagerEmail } from './reqUpdateSupplierManagerEmail'; import type { ReqUpdateSupplierManagerEmail } from './reqUpdateSupplierManagerEmail';
import type { ReqUpdateSupplierManagerContactNumber } from './reqUpdateSupplierManagerContactNumber'; import type { ReqUpdateSupplierManagerContactNumber } from './reqUpdateSupplierManagerContactNumber';
import type { ReqUpdateSupplierTotalRevenue } from './reqUpdateSupplierTotalRevenue'; import type { ReqUpdateSupplierTotalRevenue } from './reqUpdateSupplierTotalRevenue';
import type { ReqUpdateSupplierCustom } from './reqUpdateSupplierCustom';
export interface ReqUpdateSupplier { export interface ReqUpdateSupplier {
name?: ReqUpdateSupplierName; name?: ReqUpdateSupplierName;
@ -18,4 +19,5 @@ export interface ReqUpdateSupplier {
manager_email?: ReqUpdateSupplierManagerEmail; manager_email?: ReqUpdateSupplierManagerEmail;
manager_contact_number?: ReqUpdateSupplierManagerContactNumber; manager_contact_number?: ReqUpdateSupplierManagerContactNumber;
total_revenue?: ReqUpdateSupplierTotalRevenue; total_revenue?: ReqUpdateSupplierTotalRevenue;
custom?: ReqUpdateSupplierCustom;
} }

View File

@ -0,0 +1,9 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { ReqUpdateSupplierCustomAnyOf } from './reqUpdateSupplierCustomAnyOf';
export type ReqUpdateSupplierCustom = ReqUpdateSupplierCustomAnyOf | 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 ReqUpdateSupplierCustomAnyOf = { [key: string]: unknown };

View File

@ -0,0 +1,15 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { ErrorInfo } from './errorInfo';
import type { ResCompanySettingsMsg } from './resCompanySettingsMsg';
import type { ResCompanySettingsSettings } from './resCompanySettingsSettings';
export interface ResCompanySettings {
result?: ErrorInfo;
msg?: ResCompanySettingsMsg;
settings?: ResCompanySettingsSettings;
}

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 ResCompanySettingsMsg = string | null;

View File

@ -0,0 +1,9 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { ResCompanySettingsSettingsAnyOf } from './resCompanySettingsSettingsAnyOf';
export type ResCompanySettingsSettings = ResCompanySettingsSettingsAnyOf | 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 ResCompanySettingsSettingsAnyOf = { [key: string]: unknown };

View File

@ -13,6 +13,7 @@ import type { SessionDataRejectReason } from './sessionDataRejectReason';
import type { SessionDataRejectPrice } from './sessionDataRejectPrice'; import type { SessionDataRejectPrice } from './sessionDataRejectPrice';
import type { SessionDataRejectDeliveryType } from './sessionDataRejectDeliveryType'; import type { SessionDataRejectDeliveryType } from './sessionDataRejectDeliveryType';
import type { SessionDataEmailSentAt } from './sessionDataEmailSentAt'; import type { SessionDataEmailSentAt } from './sessionDataEmailSentAt';
import type { SessionDataCustom } from './sessionDataCustom';
export interface SessionData { export interface SessionData {
session_id: string; session_id: string;
@ -32,5 +33,6 @@ export interface SessionData {
reject_price?: SessionDataRejectPrice; reject_price?: SessionDataRejectPrice;
reject_delivery_type?: SessionDataRejectDeliveryType; reject_delivery_type?: SessionDataRejectDeliveryType;
email_sent_at?: SessionDataEmailSentAt; email_sent_at?: SessionDataEmailSentAt;
custom?: SessionDataCustom;
url?: string; url?: string;
} }

View File

@ -0,0 +1,9 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { SessionDataCustomAnyOf } from './sessionDataCustomAnyOf';
export type SessionDataCustom = SessionDataCustomAnyOf | 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 SessionDataCustomAnyOf = { [key: string]: unknown };

View File

@ -13,4 +13,5 @@ export interface StatKpi {
savings_delta_mom?: number; savings_delta_mom?: number;
closed_count?: number; closed_count?: number;
regen_avg_round?: number; regen_avg_round?: number;
markup_suppression_rate?: number;
} }

View File

@ -0,0 +1,11 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export interface StatMarkupPoint {
month: string;
rate?: number;
}

View File

@ -6,6 +6,7 @@
*/ */
import type { StatKpi } from './statKpi'; import type { StatKpi } from './statKpi';
import type { StatMonthPoint } from './statMonthPoint'; import type { StatMonthPoint } from './statMonthPoint';
import type { StatMarkupPoint } from './statMarkupPoint';
import type { StatOutcome } from './statOutcome'; import type { StatOutcome } from './statOutcome';
import type { StatParticipation } from './statParticipation'; import type { StatParticipation } from './statParticipation';
import type { StatTypeRow } from './statTypeRow'; import type { StatTypeRow } from './statTypeRow';
@ -15,6 +16,7 @@ import type { StatCardUsage } from './statCardUsage';
export interface StatScope { export interface StatScope {
kpi?: StatKpi; kpi?: StatKpi;
trend?: StatMonthPoint[]; trend?: StatMonthPoint[];
markup_trend?: StatMarkupPoint[];
outcome?: StatOutcome; outcome?: StatOutcome;
participation?: StatParticipation; participation?: StatParticipation;
type_split?: StatTypeRow[]; type_split?: StatTypeRow[];

View File

@ -10,6 +10,7 @@ import type { SupplierDataManagerName } from './supplierDataManagerName';
import type { SupplierDataManagerEmail } from './supplierDataManagerEmail'; import type { SupplierDataManagerEmail } from './supplierDataManagerEmail';
import type { SupplierDataManagerContactNumber } from './supplierDataManagerContactNumber'; import type { SupplierDataManagerContactNumber } from './supplierDataManagerContactNumber';
import type { SupplierDataTotalRevenue } from './supplierDataTotalRevenue'; import type { SupplierDataTotalRevenue } from './supplierDataTotalRevenue';
import type { SupplierDataCustom } from './supplierDataCustom';
import type { SupplierDataAccountLoginId } from './supplierDataAccountLoginId'; import type { SupplierDataAccountLoginId } from './supplierDataAccountLoginId';
import type { SupplierDataAccountStatus } from './supplierDataAccountStatus'; import type { SupplierDataAccountStatus } from './supplierDataAccountStatus';
import type { SupplierDataCreatedAt } from './supplierDataCreatedAt'; import type { SupplierDataCreatedAt } from './supplierDataCreatedAt';
@ -26,6 +27,7 @@ export interface SupplierData {
manager_email?: SupplierDataManagerEmail; manager_email?: SupplierDataManagerEmail;
manager_contact_number?: SupplierDataManagerContactNumber; manager_contact_number?: SupplierDataManagerContactNumber;
total_revenue?: SupplierDataTotalRevenue; total_revenue?: SupplierDataTotalRevenue;
custom?: SupplierDataCustom;
account_login_id?: SupplierDataAccountLoginId; account_login_id?: SupplierDataAccountLoginId;
account_status?: SupplierDataAccountStatus; account_status?: SupplierDataAccountStatus;
created_at?: SupplierDataCreatedAt; created_at?: SupplierDataCreatedAt;

View File

@ -0,0 +1,9 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { SupplierDataCustomAnyOf } from './supplierDataCustomAnyOf';
export type SupplierDataCustom = SupplierDataCustomAnyOf | 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 SupplierDataCustomAnyOf = { [key: string]: unknown };

View File

@ -5,6 +5,8 @@
* OpenAPI spec version: 0.1.0 * OpenAPI spec version: 0.1.0
*/ */
import type { SupplierItemDataItemCode } from './supplierItemDataItemCode'; import type { SupplierItemDataItemCode } from './supplierItemDataItemCode';
import type { SupplierItemDataItemCategory } from './supplierItemDataItemCategory';
import type { SupplierItemDataItemManufacturer } from './supplierItemDataItemManufacturer';
import type { SupplierItemDataCreatedAt } from './supplierItemDataCreatedAt'; import type { SupplierItemDataCreatedAt } from './supplierItemDataCreatedAt';
import type { SupplierItemDataUpdatedAt } from './supplierItemDataUpdatedAt'; import type { SupplierItemDataUpdatedAt } from './supplierItemDataUpdatedAt';
@ -14,6 +16,8 @@ export interface SupplierItemData {
item_name: string; item_name: string;
item_code?: SupplierItemDataItemCode; item_code?: SupplierItemDataItemCode;
supply_type: number; supply_type: number;
item_category?: SupplierItemDataItemCategory;
item_manufacturer?: SupplierItemDataItemManufacturer;
created_at?: SupplierItemDataCreatedAt; created_at?: SupplierItemDataCreatedAt;
updated_at?: SupplierItemDataUpdatedAt; updated_at?: SupplierItemDataUpdatedAt;
} }

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type SupplierItemDataItemCategory = 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 SupplierItemDataItemManufacturer = string | null;

View File

@ -13,6 +13,7 @@ import PartnersPage from '../pages/partners';
import QuotationPage from '../pages/quotation'; import QuotationPage from '../pages/quotation';
import CardsPage from '../pages/cards'; import CardsPage from '../pages/cards';
import MembersPage from '../pages/members'; import MembersPage from '../pages/members';
import SettingsPage from '../pages/settings';
import NotificationsPage from '../pages/notifications'; import NotificationsPage from '../pages/notifications';
import OnboardingPage from '../pages/onboarding'; import OnboardingPage from '../pages/onboarding';
@ -88,6 +89,12 @@ export const router = createBrowserRouter([
loader: () => (hasRole('최고관리자') ? null : redirect('/forbidden')), loader: () => (hasRole('최고관리자') ? null : redirect('/forbidden')),
Component: MembersPage, Component: MembersPage,
}, },
{
// 최고관리자 전용. 회사 브랜딩/용어/커스텀필드 설정.
path: 'settings',
loader: () => (hasRole('최고관리자') ? null : redirect('/forbidden')),
Component: SettingsPage,
},
], ],
}, },
{ {

View File

@ -12,6 +12,7 @@ const PAGE_TO_PATH: Record<PageType, string> = {
QUOTATION: '/quotation', QUOTATION: '/quotation',
CARDS: '/cards', CARDS: '/cards',
MEMBERS: '/members', MEMBERS: '/members',
SETTINGS: '/settings',
NOTIFICATIONS: '/notifications', NOTIFICATIONS: '/notifications',
}; };

View File

@ -1,6 +1,7 @@
import { useEffect, useState, type ReactNode, type ElementType } from 'react'; import { useEffect, useState, type ReactNode, type ElementType } from 'react';
import { PageType } from '@/types'; import { PageType } from '@/types';
import { useAuth } from '@/features/auth/useAuth'; import { useAuth } from '@/features/auth/useAuth';
import { useBranding } from '@/features/settings/useCompanySettings';
import { ProfileSheet } from '@/features/auth/components/ProfileSheet'; import { ProfileSheet } from '@/features/auth/components/ProfileSheet';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@ -58,7 +59,10 @@ const menuGroups: { label?: string; items: MenuItem[] }[] = [
}, },
{ {
label: '관리', label: '관리',
items: [{ type: 'MEMBERS', label: '회원관리', icon: UserCog, id: 'sidebar-members', ownerOnly: true }], items: [
{ type: 'MEMBERS', label: '회원관리', icon: UserCog, id: 'sidebar-members', ownerOnly: true },
{ type: 'SETTINGS', label: '회사 설정', icon: Building, id: 'sidebar-settings', ownerOnly: true },
],
}, },
]; ];
@ -72,11 +76,13 @@ const pageLabelMap: Record<PageType, string> = {
QUOTATION: '견적관리', QUOTATION: '견적관리',
CARDS: '협상카드관리', CARDS: '협상카드관리',
MEMBERS: '회원관리', MEMBERS: '회원관리',
SETTINGS: '회사 설정',
NOTIFICATIONS: '알림', NOTIFICATIONS: '알림',
}; };
export default function Layout({ children, currentPage, setPage, onLogout }: LayoutProps) { export default function Layout({ children, currentPage, setPage, onLogout }: LayoutProps) {
const { user } = useAuth(); const { user } = useAuth();
const branding = useBranding(); // 회사 설정 브랜딩(서비스명/로고). 미설정 시 기본 NegoData
// 기준일시(오늘) — 로컬 타임존 기준 YYYY-MM-DD // 기준일시(오늘) — 로컬 타임존 기준 YYYY-MM-DD
const today = new Date().toLocaleDateString('sv-SE'); const today = new Date().toLocaleDateString('sv-SE');
@ -142,8 +148,16 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
<div className="h-14 shrink-0 flex items-center justify-between px-4 border-b border-sidebar-border"> <div className="h-14 shrink-0 flex items-center justify-between px-4 border-b border-sidebar-border">
{expanded && ( {expanded && (
<Typography as="div" variant="body" className="flex items-center gap-2 font-extrabold tracking-tight text-foreground"> <Typography as="div" variant="body" className="flex items-center gap-2 font-extrabold tracking-tight text-foreground">
<span aria-hidden className="size-4 rounded-[5px] bg-primary" /> {branding.logoUrl ? (
NegoData <img src={branding.logoUrl} alt={branding.serviceName} className="h-4 max-w-24 object-contain" />
) : (
<span
aria-hidden
className="size-4 rounded-[5px] bg-primary"
style={branding.primaryColor ? { backgroundColor: branding.primaryColor } : undefined}
/>
)}
{branding.serviceName}
</Typography> </Typography>
)} )}

View File

@ -12,6 +12,8 @@ import { Input } from '@/components/ui/input';
import { PhoneInput } from '@/components/ui/phone-input'; import { PhoneInput } from '@/components/ui/phone-input';
import { Sheet } from '@/components/ui/sheet'; import { Sheet } from '@/components/ui/sheet';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { useCompanySettings } from '@/features/settings/useCompanySettings';
import { CustomFieldInputs, useCustomFieldValues } from '@/features/settings/CustomFieldInputs';
import { SupplierItemsManager } from './SupplierItemsManager'; import { SupplierItemsManager } from './SupplierItemsManager';
import { SupplierAccountManager } from './SupplierAccountManager'; import { SupplierAccountManager } from './SupplierAccountManager';
import { type Partner } from '../types'; import { type Partner } from '../types';
@ -83,6 +85,11 @@ export function PartnerFormSheet({
// 협력사 명부는 회사 공유 자원 — 파괴적 삭제는 최고관리자만(백엔드 RequireOwner 와 동일 규칙). // 협력사 명부는 회사 공유 자원 — 파괴적 삭제는 최고관리자만(백엔드 RequireOwner 와 동일 규칙).
const isSuperAdmin = useAuthStore((s) => s.user?.role === '최고관리자'); const isSuperAdmin = useAuthStore((s) => s.user?.role === '최고관리자');
// 회사 협력사 커스텀필드(정의=companies.settings.supplier_fields, 값=suppliers.custom)
const { settings } = useCompanySettings();
const supplierFields = settings.supplier_fields ?? [];
const customValues = useCustomFieldValues(supplierFields, partner?.custom);
const onValid = async (v: FormValues) => { const onValid = async (v: FormValues) => {
const common = { const common = {
name: v.name, name: v.name,
@ -91,6 +98,7 @@ export function PartnerFormSheet({
manager_email: v.managerEmail, manager_email: v.managerEmail,
manager_contact_number: v.managerPhone, manager_contact_number: v.managerPhone,
total_revenue: v.totalRevenue?.trim() ? Number(v.totalRevenue.replace(/[^0-9]/g, '')) : undefined, total_revenue: v.totalRevenue?.trim() ? Number(v.totalRevenue.replace(/[^0-9]/g, '')) : undefined,
...(supplierFields.length > 0 ? { custom: customValues.values } : {}),
}; };
if (mode === 'create') { if (mode === 'create') {
@ -213,6 +221,9 @@ export function PartnerFormSheet({
{errors.managerPhone && <p className="text-[10px] text-rose-500">{errors.managerPhone.message}</p>} {errors.managerPhone && <p className="text-[10px] text-rose-500">{errors.managerPhone.message}</p>}
</div> </div>
{/* 회사 커스텀 필드 — companies.settings.supplier_fields 정의대로 렌더, suppliers.custom 에 저장 */}
<CustomFieldInputs fields={supplierFields} state={customValues} title="회사 추가 항목" />
{/* 취급상품 관리 — 수정 모드(협력사 확정)에서만. 추가/삭제/유형변경은 즉시 서버 반영. */} {/* 취급상품 관리 — 수정 모드(협력사 확정)에서만. 추가/삭제/유형변경은 즉시 서버 반영. */}
{mode === 'edit' && partner && <SupplierItemsManager supplierId={partner.supplier_id} />} {mode === 'edit' && partner && <SupplierItemsManager supplierId={partner.supplier_id} />}

View File

@ -5,6 +5,8 @@ import type { Partner } from '../types';
type PartnerTableProps = { type PartnerTableProps = {
data: Partner[]; data: Partner[];
selectedIds: string[];
onSelectionChange: (ids: string[]) => void;
onRowClick: (part: Partner) => void; onRowClick: (part: Partner) => void;
page: number; page: number;
totalPages: number; totalPages: number;
@ -15,6 +17,8 @@ type PartnerTableProps = {
export function PartnerTable({ export function PartnerTable({
data, data,
selectedIds,
onSelectionChange,
onRowClick, onRowClick,
page, page,
totalPages, totalPages,
@ -27,6 +31,7 @@ export function PartnerTable({
data={data} data={data}
rowKey={(part) => part.supplier_id} rowKey={(part) => part.supplier_id}
onRowClick={onRowClick} onRowClick={onRowClick}
selection={{ selectedKeys: selectedIds, onSelectionChange }}
empty="협약된 가용 B2B 파트너사가 존재하지 않습니다." empty="협약된 가용 B2B 파트너사가 존재하지 않습니다."
footer={ footer={
<TablePagination <TablePagination

View File

@ -62,6 +62,7 @@ export function SupplierItemsManager({ supplierId }: { supplierId: string }) {
} }
}; };
// 분류카테고리 파생(IMK #17, B안) — 취급상품에서 (카테고리-제조원, 공급유형) distinct 집계.
return ( return (
<div className="pt-4 border-t border-border space-y-2"> <div className="pt-4 border-t border-border space-y-2">
<Typography as="label" variant="label"> ({items.length})</Typography> <Typography as="label" variant="label"> ({items.length})</Typography>
@ -110,7 +111,9 @@ export function SupplierItemsManager({ supplierId }: { supplierId: string }) {
items.map((m) => ( items.map((m) => (
<div key={m.supplier_item_id} className="flex items-center justify-between gap-2 p-2"> <div key={m.supplier_item_id} className="flex items-center justify-between gap-2 p-2">
<div className="min-w-0"> <div className="min-w-0">
<Typography as="span" variant="small" className="font-semibold block truncate">{m.item_name}</Typography> <Typography as="span" variant="small" className="font-semibold block truncate">
{m.item_category ? `${m.item_category} - ${m.item_name}` : m.item_name}
</Typography>
{m.item_code && ( {m.item_code && (
<Typography as="span" variant="small" className="text-muted-foreground text-[10px]">{m.item_code}</Typography> <Typography as="span" variant="small" className="text-muted-foreground text-[10px]">{m.item_code}</Typography>
)} )}

View File

@ -8,6 +8,8 @@ import { customFetch } from '@/api/mutator/custom-fetch';
import { Typography } from '@/components/ui/typography'; import { Typography } from '@/components/ui/typography';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { useCompanySettings, useLabels } from '@/features/settings/useCompanySettings';
import type { CustomFieldDef } from '@/features/settings/catalog';
import type { Product } from '../types'; import type { Product } from '../types';
// 엑셀에서 읽어온 원본 행(입력값만). status/message는 저장하지 않고 검증에서 파생한다. // 엑셀에서 읽어온 원본 행(입력값만). status/message는 저장하지 않고 검증에서 파생한다.
@ -29,20 +31,16 @@ type RawRow = {
moq: string; moq: string;
lead_time: number; lead_time: number;
quantity_unit: string; quantity_unit: string;
delivery_type: string; // 한글 라벨로 입력 → 전송 시 코드 변환 delivery_type: string; // 라벨로 입력 → 전송 시 코드 변환
vat_yn: string; // Y/N vat_yn: string; // Y/N
delivery_fee_yn: string; // Y/N delivery_fee_yn: string; // Y/N
custom: Record<string, string>; // 회사 커스텀필드(item_fields) 값 — 전송 시 타입 변환
}; };
// 검증 결과가 붙은 행. UI는 이걸 그린다. // 검증 결과가 붙은 행. UI는 이걸 그린다.
type ValidatedRow = RawRow & { status: '정상' | '오류'; message: string }; type ValidatedRow = RawRow & { status: '정상' | '오류'; message: string };
// 배송형태 라벨 ↔ delivery_type 코드(공용 enum 과 동일 집합) type LabelFn = (key: string) => string;
const DELIVERY_LABEL_TO_CODE: Record<string, number> = {
협력사배송: 1,
지정택배배송: 2,
픽업배송: 3,
};
// 자유로운 Y/N 표기 → boolean (Y·예·true·O·포함·1 = true) // 자유로운 Y/N 표기 → boolean (Y·예·true·O·포함·1 = true)
const parseYn = (s: string): boolean => /^(y|yes|true|1|예|o|포함)$/i.test(s.trim()); const parseYn = (s: string): boolean => /^(y|yes|true|1|예|o|포함)$/i.test(s.trim());
@ -51,31 +49,71 @@ const parseYn = (s: string): boolean => /^(y|yes|true|1|예|o|포함)$/i.test(s.
const isYnToken = (s: string): boolean => const isYnToken = (s: string): boolean =>
/^(y|yes|true|1|예|o|포함|n|no|false|0|아니오|x|미포함)$/i.test(s.trim()); /^(y|yes|true|1|예|o|포함|n|no|false|0|아니오|x|미포함)$/i.test(s.trim());
// 업로드 양식(.csv) 컬럼 정의 — 헤더 ↔ RawRow 필드. 양식/예시/파싱이 이 한 곳을 공유한다. // 표준 컬럼(고정 18개 — 삭제 없음). labelKey 가 있으면 헤더를 회사 설정 용어로 치환하고,
const UPLOAD_COLUMNS: { header: string; key: keyof RawRow }[] = [ // base 헤더는 구양식 파일 호환용 별칭으로 계속 인식한다.
{ header: '상품명', key: 'name' }, const STANDARD_COLUMNS: { base: string; key: Exclude<keyof RawRow, 'id' | 'rowNum' | 'custom'>; labelKey?: string; suffix?: string }[] = [
{ header: '상품코드', key: 'code' }, { base: '상품명', key: 'name' },
{ header: '모델번호', key: 'model_name' }, { base: '상품코드', key: 'code' },
{ header: '카테고리', key: 'category' }, { base: '모델번호', key: 'model_name' },
{ header: '규격', key: 'spec' }, { base: '카테고리', key: 'category', labelKey: 'category' },
{ header: '제조사', key: 'manufacturer' }, { base: '규격', key: 'spec' },
{ header: '원산지', key: 'made_in' }, { base: '제조사', key: 'manufacturer' },
{ header: '상품 단가', key: 'price' }, { base: '원산지', key: 'made_in' },
{ header: '최저한도', key: 'minPrice' }, { base: '상품 단가', key: 'price', labelKey: 'item.price' },
{ header: '매입가', key: 'purchase_price' }, { base: '최저한도', key: 'minPrice' },
{ header: '판매가', key: 'selling_price' }, { base: '매입가', key: 'purchase_price' },
{ header: '이미지URL', key: 'image_url' }, { base: '판매가', key: 'selling_price' },
{ header: '최소주문수량', key: 'moq' }, { base: '이미지URL', key: 'image_url' },
{ header: '리드타임(일)', key: 'lead_time' }, { base: '최소주문수량', key: 'moq' },
{ header: '단위', key: 'quantity_unit' }, { base: '리드타임(일)', key: 'lead_time', labelKey: 'lead_time', suffix: '(일)' },
{ header: '배송형태', key: 'delivery_type' }, { base: '단위', key: 'quantity_unit' },
{ header: '부가세포함(Y/N)', key: 'vat_yn' }, { base: '배송형태', key: 'delivery_type' },
{ header: '배송비포함(Y/N)', key: 'delivery_fee_yn' }, { base: '부가세포함(Y/N)', key: 'vat_yn' },
{ base: '배송비포함(Y/N)', key: 'delivery_fee_yn' },
]; ];
// 숫자 입력 컬럼 / 필수 컬럼(헤더에 * 표기) // 렌더/파싱/양식이 공유하는 컬럼(헤더는 회사 설정 반영). customKey 있으면 커스텀필드 컬럼.
const NUMERIC_KEYS = new Set<keyof RawRow>(['price', 'minPrice', 'purchase_price', 'selling_price', 'lead_time']); type UploadColumn = {
const REQUIRED_KEYS = new Set<keyof RawRow>(['name', 'code', 'price']); header: string;
aliases: string[]; // 파싱 시 인식할 헤더 후보(회사 라벨 + 기본 헤더)
key?: Exclude<keyof RawRow, 'id' | 'rowNum' | 'custom'>;
customKey?: string;
numeric: boolean;
required: boolean;
};
const NUMERIC_KEYS = new Set<string>(['price', 'minPrice', 'purchase_price', 'selling_price', 'lead_time']);
const REQUIRED_KEYS = new Set<string>(['name', 'code', 'price']);
// 회사 설정(용어 라벨 + item_fields 커스텀필드)으로 업로드 컬럼을 만든다.
// 기존 18컬럼은 전부 유지, 커스텀필드는 뒤에 추가된다.
function buildColumns(label: LabelFn, itemFields: CustomFieldDef[]): UploadColumn[] {
const standard: UploadColumn[] = STANDARD_COLUMNS.map((c) => {
const header = c.labelKey ? `${label(c.labelKey)}${c.suffix ?? ''}` : c.base;
return {
header,
aliases: [...new Set([header, c.base])],
key: c.key,
numeric: NUMERIC_KEYS.has(c.key),
required: REQUIRED_KEYS.has(c.key),
};
});
const custom: UploadColumn[] = itemFields.map((f) => ({
header: f.type === 'boolean' ? `${f.label}(Y/N)` : f.label,
aliases: [f.type === 'boolean' ? `${f.label}(Y/N)` : f.label, f.label],
customKey: f.key,
numeric: f.type === 'number',
required: false,
}));
return [...standard, ...custom];
}
// 배송형태 라벨 → 코드. 기본 라벨과 회사 설정 라벨(직납 등)을 모두 인식한다.
function buildDeliveryMap(label: LabelFn): Record<string, number> {
const map: Record<string, number> = { 협력사배송: 1, 지정택배배송: 2, 픽업배송: 3 };
for (const code of [1, 2, 3]) map[label(`delivery_type.${code}`)] = code;
return map;
}
// 양식에 채워 넣는 예시 행(시드 상품과 동일 셋). 다운로드 양식에 그대로 들어간다. // 양식에 채워 넣는 예시 행(시드 상품과 동일 셋). 다운로드 양식에 그대로 들어간다.
const EXAMPLE_ROWS: Record<string, string | number>[] = [ const EXAMPLE_ROWS: Record<string, string | number>[] = [
@ -83,23 +121,32 @@ const EXAMPLE_ROWS: Record<string, string | number>[] = [
name: '리튬인산철 배터리 모듈', code: 'BAT-LFP-100', model_name: 'LFP-100A', name: '리튬인산철 배터리 모듈', code: 'BAT-LFP-100', model_name: 'LFP-100A',
category: '에너지/배터리', spec: '3.2V 100Ah', manufacturer: '한성에너지', made_in: '대한민국', category: '에너지/배터리', spec: '3.2V 100Ah', manufacturer: '한성에너지', made_in: '대한민국',
price: 1250000, minPrice: 1037500, purchase_price: 1000000, selling_price: 1250000, image_url: 'https://example.com/img/lfp-100a.jpg', price: 1250000, minPrice: 1037500, purchase_price: 1000000, selling_price: 1250000, image_url: 'https://example.com/img/lfp-100a.jpg',
moq: '10 EA', lead_time: 14, quantity_unit: 'EA', delivery_type: '협력사배송', moq: '10 EA', lead_time: 14, quantity_unit: 'EA', delivery_type: 1,
vat_yn: 'Y', delivery_fee_yn: 'N', vat_yn: 'Y', delivery_fee_yn: 'N',
}, },
{ {
name: '산업용 6축 로봇암', code: 'ROB-6AX-22', model_name: 'RX-6A', name: '산업용 6축 로봇암', code: 'ROB-6AX-22', model_name: 'RX-6A',
category: '자동화설비', spec: '가반하중 12kg', manufacturer: '오토메카', made_in: '일본', category: '자동화설비', spec: '가반하중 12kg', manufacturer: '오토메카', made_in: '일본',
price: 18900000, minPrice: 16065000, purchase_price: 15000000, selling_price: 18900000, image_url: 'https://example.com/img/rx-6a.jpg', price: 18900000, minPrice: 16065000, purchase_price: 15000000, selling_price: 18900000, image_url: 'https://example.com/img/rx-6a.jpg',
moq: '1 EA', lead_time: 30, quantity_unit: 'EA', delivery_type: '지정택배배송', moq: '1 EA', lead_time: 30, quantity_unit: 'EA', delivery_type: 2,
vat_yn: 'Y', delivery_fee_yn: 'N', vat_yn: 'Y', delivery_fee_yn: 'N',
}, },
]; ];
// 업로드 양식(.csv) 다운로드 — 전체 컬럼 헤더 + 예시 행(시드 상품 셋). UPLOAD_COLUMNS 단일 정의 공유. 툴바·모달이 공유한다. // 업로드 양식(.csv) 다운로드 — 회사 설정 헤더(라벨 치환) + 커스텀필드 컬럼 + 예시 행.
export function downloadProductTemplate() { // 파싱(buildColumns)과 같은 정의를 공유하므로 받은 양식이 그대로 다시 업로드된다. 툴바·모달이 공유한다.
export function downloadProductTemplate(label: LabelFn, itemFields: CustomFieldDef[]) {
const columns = buildColumns(label, itemFields);
downloadExcel<Record<string, string | number>>( downloadExcel<Record<string, string | number>>(
'상품_업로드_양식', '상품_업로드_양식',
UPLOAD_COLUMNS.map((c) => ({ header: c.header, value: (r) => r[c.key] })), columns.map((c) => ({
header: c.header,
value: (r) => {
if (c.customKey) return c.numeric ? 10 : ''; // 커스텀 예시값(숫자=10, 그 외 빈칸)
if (c.key === 'delivery_type') return label(`delivery_type.${r.delivery_type}`); // 예시도 회사 라벨로
return r[c.key as string];
},
})),
EXAMPLE_ROWS, EXAMPLE_ROWS,
); );
} }
@ -117,6 +164,10 @@ function validateRows(
rows: RawRow[], rows: RawRow[],
products: Product[], products: Product[],
serverErrors: Record<string, string>, serverErrors: Record<string, string>,
deliveryMap: Record<string, number>,
deliveryLabels: string[],
priceLabel: string,
itemFields: CustomFieldDef[],
): ValidatedRow[] { ): ValidatedRow[] {
return rows.map((row) => { return rows.map((row) => {
const fail = (message: string): ValidatedRow => ({ ...row, status: '오류', message }); const fail = (message: string): ValidatedRow => ({ ...row, status: '오류', message });
@ -128,12 +179,12 @@ function validateRows(
const dupInExcel = rows.some((other) => other.id !== row.id && other.code === row.code); const dupInExcel = rows.some((other) => other.id !== row.id && other.code === row.code);
if (dupInProducts || dupInExcel) return fail('코드 중복 - 이미 존재하거나 목록 내 중복된 코드입니다.'); if (dupInProducts || dupInExcel) return fail('코드 중복 - 이미 존재하거나 목록 내 중복된 코드입니다.');
if (row.price <= 0) return fail('유효성 위반 - 상품 단가는 0보다 커야 합니다.'); if (row.price <= 0) return fail(`유효성 위반 - ${priceLabel}는 0보다 커야 합니다.`);
if (row.minPrice > row.price) return fail('유효성 위반 - 최저 한도가 상품 단가보다 큽니다.'); if (row.minPrice > row.price) return fail(`유효성 위반 - 최저 한도가 ${priceLabel}보다 큽니다.`);
// 선택 필드 형식 검증(값이 있을 때만). 배송형태/부가세/배송비/이미지URL. // 선택 필드 형식 검증(값이 있을 때만). 배송형태/부가세/배송비/이미지URL.
if (row.delivery_type.trim() && !(row.delivery_type.trim() in DELIVERY_LABEL_TO_CODE)) { if (row.delivery_type.trim() && !(row.delivery_type.trim() in deliveryMap)) {
return fail('배송형태 - 협력사배송 / 지정택배배송 / 픽업배송 중 하나여야 합니다.'); return fail(`배송형태 - ${deliveryLabels.join(' / ')} 중 하나여야 합니다.`);
} }
if (row.vat_yn.trim() && !isYnToken(row.vat_yn)) { if (row.vat_yn.trim() && !isYnToken(row.vat_yn)) {
return fail('부가세포함 - Y 또는 N(예/아니오)으로 입력해 주십시오.'); return fail('부가세포함 - Y 또는 N(예/아니오)으로 입력해 주십시오.');
@ -144,6 +195,13 @@ function validateRows(
if (row.image_url.trim() && !/^https?:\/\//i.test(row.image_url.trim())) { if (row.image_url.trim() && !/^https?:\/\//i.test(row.image_url.trim())) {
return fail('이미지URL - http:// 또는 https:// 로 시작하는 주소여야 합니다.'); return fail('이미지URL - http:// 또는 https:// 로 시작하는 주소여야 합니다.');
} }
// 커스텀필드 형식 검증(값이 있을 때만). boolean=Y/N 토큰, number=숫자.
for (const f of itemFields) {
const v = (row.custom[f.key] ?? '').trim();
if (!v) continue;
if (f.type === 'boolean' && !isYnToken(v)) return fail(`${f.label} - Y 또는 N(예/아니오)으로 입력해 주십시오.`);
if (f.type === 'number' && Number.isNaN(Number(v))) return fail(`${f.label} - 숫자로 입력해 주십시오.`);
}
// 프론트 검증 통과 후, 직전 전송에서 서버가 거부한 코드면 그 사유로 오류 처리. // 프론트 검증 통과 후, 직전 전송에서 서버가 거부한 코드면 그 사유로 오류 처리.
if (serverErrors[row.code]) return fail(serverErrors[row.code]); if (serverErrors[row.code]) return fail(serverErrors[row.code]);
@ -153,7 +211,14 @@ function validateRows(
} }
// 검증된(정상) 행 → 서버 생성 payload. 엑셀 업로드 공통 기본값 적용. // 검증된(정상) 행 → 서버 생성 payload. 엑셀 업로드 공통 기본값 적용.
function toItemCreate(row: RawRow): ItemCreate { function toItemCreate(row: RawRow, deliveryMap: Record<string, number>, itemFields: CustomFieldDef[]): ItemCreate {
// 커스텀필드 문자열 → 정의 타입대로 변환(빈 값은 제외)
const custom: Record<string, unknown> = {};
for (const f of itemFields) {
const v = (row.custom[f.key] ?? '').trim();
if (!v) continue;
custom[f.key] = f.type === 'number' ? Number(v) : f.type === 'boolean' ? parseYn(v) : v;
}
return { return {
name: row.name, name: row.name,
code: row.code, code: row.code,
@ -169,19 +234,27 @@ function toItemCreate(row: RawRow): ItemCreate {
moq: row.moq || undefined, moq: row.moq || undefined,
lead_time: row.lead_time || undefined, lead_time: row.lead_time || undefined,
quantity_unit: row.quantity_unit || undefined, quantity_unit: row.quantity_unit || undefined,
delivery_type: DELIVERY_LABEL_TO_CODE[row.delivery_type.trim()] ?? undefined, delivery_type: deliveryMap[row.delivery_type.trim()] ?? undefined,
vat_yn: row.vat_yn.trim() ? parseYn(row.vat_yn) : undefined, vat_yn: row.vat_yn.trim() ? parseYn(row.vat_yn) : undefined,
delivery_fee_yn: row.delivery_fee_yn.trim() ? parseYn(row.delivery_fee_yn) : undefined, delivery_fee_yn: row.delivery_fee_yn.trim() ? parseYn(row.delivery_fee_yn) : undefined,
// minPrice(최저한도) = 인터넷 최저가(실값) → internet_lowest_price 로 저장(수기 폼과 동일). // minPrice(최저한도) = 인터넷 최저가(실값) → internet_lowest_price 로 저장(수기 폼과 동일).
internet_lowest_price: row.minPrice, internet_lowest_price: row.minPrice,
internet_lowest_price_yn: row.minPrice > 0, internet_lowest_price_yn: row.minPrice > 0,
...(Object.keys(custom).length > 0 ? { custom } : {}),
}; };
} }
// 상품 엑셀 일괄 업로드 모달. 파일 파싱(목업)·원본 행 state는 이 컴포넌트가 소유하고, // 상품 엑셀 일괄 업로드 모달. 파일 파싱·원본 행 state는 이 컴포넌트가 소유하고,
// 검증은 렌더 시 validateRows로 파생한다. 실제 서버 등록은 onConfirm(검증된 행)으로 위임. // 검증은 렌더 시 validateRows로 파생한다. 실제 서버 등록은 onConfirm(검증된 행)으로 위임.
// 컬럼(헤더 라벨·커스텀필드)은 회사 설정을 따른다 — 양식 다운로드와 동일 정의.
export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUploadModalProps) { export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUploadModalProps) {
useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금 useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금
const label = useLabels();
const { settings } = useCompanySettings();
const itemFields = useMemo(() => settings.item_fields ?? [], [settings.item_fields]);
const columns = useMemo(() => buildColumns(label, itemFields), [label, itemFields]);
const deliveryMap = useMemo(() => buildDeliveryMap(label), [label]);
const [excelFile, setExcelFile] = useState<string | null>(null); const [excelFile, setExcelFile] = useState<string | null>(null);
const [rows, setRows] = useState<RawRow[]>([]); const [rows, setRows] = useState<RawRow[]>([]);
const [serverErrors, setServerErrors] = useState<Record<string, string>>({}); // 서버(DB) 거부 code→사유 const [serverErrors, setServerErrors] = useState<Record<string, string>>({}); // 서버(DB) 거부 code→사유
@ -190,8 +263,12 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
// 파생: 검증 결과 + 카운트 (state로 저장하지 않음) // 파생: 검증 결과 + 카운트 (state로 저장하지 않음)
const validated = useMemo( const validated = useMemo(
() => validateRows(rows, products, serverErrors), () => validateRows(
[rows, products, serverErrors], rows, products, serverErrors, deliveryMap,
[1, 2, 3].map((c) => label(`delivery_type.${c}`)),
label('item.price'), itemFields,
),
[rows, products, serverErrors, deliveryMap, label, itemFields],
); );
const validRows = validated.filter((r) => r.status === '정상'); const validRows = validated.filter((r) => r.status === '정상');
const validCount = validRows.length; const validCount = validRows.length;
@ -206,31 +283,31 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
onClose(); onClose();
}; };
// 업로드된 CSV를 파싱해 원본 행으로 적재(검증은 자동 파생). 헤더는 양식과 동일해야 함. // 업로드된 CSV를 파싱해 원본 행으로 적재(검증은 자동 파생).
// 헤더는 회사 라벨과 기본 헤더(구양식) 둘 다 인식한다(aliases).
const handleFile = async (file: File) => { const handleFile = async (file: File) => {
const parsed = parseCsv(await file.text()); const parsed = parseCsv(await file.text());
const loaded: RawRow[] = parsed.map((r, i) => ({ const pick = (r: Record<string, string>, c: UploadColumn): string => {
id: `row-${i + 1}`, for (const a of c.aliases) if (r[a] !== undefined) return r[a];
rowNum: i + 2, return '';
name: r['상품명'] ?? '', };
code: r['상품코드'] ?? '', const loaded: RawRow[] = parsed.map((r, i) => {
model_name: r['모델번호'] ?? '', const row: RawRow = {
category: r['카테고리'] ?? '', id: `row-${i + 1}`,
spec: r['규격'] ?? '', rowNum: i + 2,
manufacturer: r['제조사'] ?? '', name: '', code: '', model_name: '', category: '', spec: '', manufacturer: '', made_in: '',
made_in: r['원산지'] ?? '', price: 0, minPrice: 0, purchase_price: 0, selling_price: 0,
price: Number(r['상품 단가']) || 0, image_url: '', moq: '', lead_time: 0, quantity_unit: '', delivery_type: '', vat_yn: '', delivery_fee_yn: '',
minPrice: Number(r['최저한도']) || 0, custom: {},
purchase_price: Number(r['매입가']) || 0, };
selling_price: Number(r['판매가']) || 0, for (const c of columns) {
image_url: r['이미지URL'] ?? '', const v = pick(r, c);
moq: r['최소주문수량'] ?? '', if (c.customKey) row.custom[c.customKey] = v;
lead_time: Number(r['리드타임(일)']) || 0, else if (c.numeric) (row[c.key!] as number) = Number(v) || 0;
quantity_unit: r['단위'] ?? '', else (row[c.key!] as string) = v;
delivery_type: r['배송형태'] ?? '', }
vat_yn: r['부가세포함(Y/N)'] ?? '', return row;
delivery_fee_yn: r['배송비포함(Y/N)'] ?? '', });
}));
setExcelFile(file.name); setExcelFile(file.name);
setRows(loaded); setRows(loaded);
setServerErrors({}); // 새 파일 → 직전 서버사유 초기화 setServerErrors({}); // 새 파일 → 직전 서버사유 초기화
@ -252,15 +329,14 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
} }
}; };
// 인라인 편집 — 원본 필드만 갱신(재검증은 파생이 처리). 숫자 컬럼은 정수화. // 인라인 편집 — 원본 필드만 갱신(재검증은 파생이 처리). 표준 숫자 컬럼은 정수화, 커스텀은 문자열 보관.
const handleUpdateField = (id: string, field: keyof RawRow, value: string) => { const handleUpdateField = (id: string, c: UploadColumn, value: string) => {
setRows((cur) => setRows((cur) =>
cur.map((row) => { cur.map((row) => {
if (row.id !== id) return row; if (row.id !== id) return row;
if (NUMERIC_KEYS.has(field)) { if (c.customKey) return { ...row, custom: { ...row.custom, [c.customKey]: value } };
return { ...row, [field]: Math.max(0, parseInt(value, 10) || 0) }; if (c.numeric) return { ...row, [c.key!]: Math.max(0, parseInt(value, 10) || 0) };
} return { ...row, [c.key!]: value };
return { ...row, [field]: value };
}), }),
); );
}; };
@ -276,7 +352,7 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
return; return;
} }
try { try {
const failures = await onConfirm(validRows.map(toItemCreate)); const failures = await onConfirm(validRows.map((r) => toItemCreate(r, deliveryMap, itemFields)));
const okCount = validRows.length - failures.length; const okCount = validRows.length - failures.length;
if (failures.length === 0) { if (failures.length === 0) {
showToast(`${okCount}개 상품이 서버에 일괄 등록되었습니다.`, 'success'); showToast(`${okCount}개 상품이 서버에 일괄 등록되었습니다.`, 'success');
@ -381,12 +457,12 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
<TableHead className="p-2 font-semibold text-center w-12"></TableHead> <TableHead className="p-2 font-semibold text-center w-12"></TableHead>
<TableHead className="p-2 font-semibold text-center whitespace-nowrap"></TableHead> <TableHead className="p-2 font-semibold text-center whitespace-nowrap"></TableHead>
<TableHead className="p-2 font-semibold whitespace-nowrap"> </TableHead> <TableHead className="p-2 font-semibold whitespace-nowrap"> </TableHead>
{UPLOAD_COLUMNS.map((c) => ( {columns.map((c) => (
<TableHead <TableHead
key={c.key} key={c.header}
className={`p-2 font-semibold whitespace-nowrap ${NUMERIC_KEYS.has(c.key) ? 'text-right' : ''}`} className={`p-2 font-semibold whitespace-nowrap ${c.numeric ? 'text-right' : ''}`}
> >
{c.header}{REQUIRED_KEYS.has(c.key) ? ' *' : ''} {c.header}{c.required ? ' *' : ''}
</TableHead> </TableHead>
))} ))}
</TableRow> </TableRow>
@ -417,16 +493,16 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
<TableCell className={`p-2 font-mono text-[10px] whitespace-nowrap ${row.status === '오류' ? 'text-rose-500' : 'text-emerald-600'}`}> <TableCell className={`p-2 font-mono text-[10px] whitespace-nowrap ${row.status === '오류' ? 'text-rose-500' : 'text-emerald-600'}`}>
{row.message} {row.message}
</TableCell> </TableCell>
{UPLOAD_COLUMNS.map((c) => { {columns.map((c) => {
const isNum = NUMERIC_KEYS.has(c.key); const value = c.customKey ? row.custom[c.customKey] ?? '' : row[c.key!];
return ( return (
<TableCell key={c.key} className={`p-2 ${isNum ? 'text-right' : ''}`}> <TableCell key={c.header} className={`p-2 ${c.numeric ? 'text-right' : ''}`}>
<Input <Input
type={isNum ? 'number' : 'text'} type={c.numeric && !c.customKey ? 'number' : 'text'}
placeholder={c.header} placeholder={c.header}
className={`bg-muted/20 hover:bg-muted/50 ${isNum ? 'w-24 text-right font-mono' : 'min-w-[110px] font-mono'}`} className={`bg-muted/20 hover:bg-muted/50 ${c.numeric ? 'w-24 text-right font-mono' : 'min-w-[110px] font-mono'}`}
value={String(row[c.key] ?? '')} value={String(value ?? '')}
onChange={(e) => handleUpdateField(row.id, c.key, e.target.value)} onChange={(e) => handleUpdateField(row.id, c, e.target.value)}
/> />
</TableCell> </TableCell>
); );

View File

@ -0,0 +1,146 @@
import { useState } from 'react';
import { Plus, Trash2 } from 'lucide-react';
import { useListSuppliers } from '@/api/generated/supplier/supplier';
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, SupplierType, supplierTypeLabel } from '@/lib/enumLabels';
import { showToast } from '@/lib/notify';
import { useItemSuppliers } from '../hooks/useItemSuppliers';
// 상품 상세의 공급사 관리 섹션(IMK #20) — SupplierItemsManager(협력사측)의 상품측 미러.
// 공급사 추가/삭제 + 공급유형(제조/유통/총판/없음) 수정. 각 조작은 즉시 서버 반영(상품 기본정보 저장과 독립).
export function ItemSuppliersManager({ itemId }: { itemId: string }) {
const { suppliers, isLoading, addSupplier, changeType, removeSupplier } = useItemSuppliers(itemId);
const [q, setQ] = useState('');
const catalogQuery = useListSuppliers({ search: q || undefined, size: 30 }); // 서버검색
const [pickSupplierId, setPickSupplierId] = useState('');
const [pickLabel, setPickLabel] = useState('');
const [pickType, setPickType] = useState(String(SupplierType.NONE)); // 기본 없음(0)
const [busy, setBusy] = useState(false);
const mappedIds = new Set(suppliers.map((m) => m.supplier_id));
const options: ComboOption[] = (catalogQuery.data?.suppliers ?? [])
.filter((s) => !mappedIds.has(s.supplier_id))
.map((s) => ({ id: s.supplier_id, label: `${s.name}${s.code ? ` [${s.code}]` : ''}` }));
const handleAdd = async () => {
if (!pickSupplierId) {
showToast('추가할 공급사를 선택하세요.', 'error');
return;
}
setBusy(true);
try {
await addSupplier(pickSupplierId, Number(pickType));
setPickSupplierId('');
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 removeSupplier(supplierItemId);
showToast('공급사가 삭제되었습니다.', 'info');
} catch {
showToast('공급사 삭제에 실패했습니다.', 'error');
}
};
return (
<div className="pt-4 border-t border-border space-y-2">
<Typography as="label" variant="label"> ({suppliers.length})</Typography>
{/* 추가 행 — 공급사 + 공급유형 선택 후 추가 */}
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0">
<Combobox
id="item-supplier-pick"
options={options}
loading={catalogQuery.isLoading}
onQueryChange={setQ}
value={pickSupplierId || undefined}
selectedLabel={pickLabel}
onSelect={(opt) => { setPickSupplierId(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="item-supplier-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 || !pickSupplierId}>
<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>
) : suppliers.length === 0 ? (
<Typography as="p" variant="small" className="p-3 text-muted-foreground text-[11px]"> .</Typography>
) : (
suppliers.map((m) => (
<div key={m.supplier_id} className="flex items-center gap-2 p-2">
<div className="flex-1 min-w-0">
<Typography as="span" variant="small" className="font-semibold block truncate">
{m.supplier_name ?? '-'}
</Typography>
</div>
<div className="w-24 shrink-0">
<Select
value={String(m.supply_type)}
onValueChange={(v) => v != null && m.supplier_item_id && handleChangeType(String(m.supplier_item_id), v)}
>
<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={() => m.supplier_item_id && handleRemove(String(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>
);
}

View File

@ -13,6 +13,9 @@ import { Input } from '@/components/ui/input';
import { Sheet } from '@/components/ui/sheet'; import { Sheet } from '@/components/ui/sheet';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { useCompanySettings, useLabels } from '@/features/settings/useCompanySettings';
import { CustomFieldInputs, useCustomFieldValues } from '@/features/settings/CustomFieldInputs';
import { ItemSuppliersManager } from './ItemSuppliersManager';
import { type Product } from '../types'; import { type Product } from '../types';
// 폼 검증 스키마. 필수: 상품명/상품코드/단가/최저가. 나머지는 선택. // 폼 검증 스키마. 필수: 상품명/상품코드/단가/최저가. 나머지는 선택.
@ -128,7 +131,13 @@ export function ProductFormSheet({
defaultValues: buildDefaults(mode, product), defaultValues: buildDefaults(mode, product),
}); });
const deliveryTypes = DELIVERY_TYPE_OPTIONS; const label = useLabels(); // 회사 설정 용어
// 배송유형 선택지 — 회사 설정 용어(delivery_type.N)로 라벨만 치환(코드값 불변)
const deliveryTypes = DELIVERY_TYPE_OPTIONS.map((o) => ({ ...o, label: label(`delivery_type.${o.value}`) }));
// 회사 상품 커스텀필드(정의=companies.settings.item_fields, 값=items.custom)
const { settings } = useCompanySettings();
const itemFields = settings.item_fields ?? [];
const customValues = useCustomFieldValues(itemFields, product?.custom);
// 소유자 게이팅 — 본인이 등록한 상품 또는 최고관리자만 수정·삭제(프론트 1차 차단, 백엔드도 강제). // 소유자 게이팅 — 본인이 등록한 상품 또는 최고관리자만 수정·삭제(프론트 1차 차단, 백엔드도 강제).
const myUserId = useAuthStore((s) => s.user?.userId); const myUserId = useAuthStore((s) => s.user?.userId);
@ -162,6 +171,7 @@ export function ProductFormSheet({
internet_lowest_price: v.minPrice, internet_lowest_price: v.minPrice,
purchase_price: v.purchasePrice, purchase_price: v.purchasePrice,
selling_price: v.sellingPrice, selling_price: v.sellingPrice,
...(itemFields.length > 0 ? { custom: customValues.values } : {}),
}; };
if (mode === 'create') { if (mode === 'create') {
@ -235,7 +245,7 @@ export function ProductFormSheet({
</div> </div>
{/* Category — 기존(서버 items distinct) 선택 또는 새 카테고리 직접 입력(datalist 콤보) */} {/* Category — 기존(서버 items distinct) 선택 또는 새 카테고리 직접 입력(datalist 콤보) */}
<div className="space-y-1"> <div className="space-y-1">
<Typography as="label" variant="label"> </Typography> <Typography as="label" variant="label">{label('category')}</Typography>
<Input <Input
id="form-product-category" id="form-product-category"
list="form-product-category-options" list="form-product-category-options"
@ -263,7 +273,7 @@ export function ProductFormSheet({
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
{/* Price */} {/* Price */}
<div className="space-y-1"> <div className="space-y-1">
<Typography as="label" variant="label"> ()</Typography> <Typography as="label" variant="label">{label('item.price')} ()</Typography>
<Input <Input
id="form-product-price" id="form-product-price"
type="number" type="number"
@ -359,7 +369,7 @@ export function ProductFormSheet({
</div> </div>
{/* Lead Time */} {/* Lead Time */}
<div className="space-y-1"> <div className="space-y-1">
<Typography as="label" variant="label"> ()</Typography> <Typography as="label" variant="label">{label('lead_time')} ()</Typography>
<Input <Input
type="number" type="number"
{...register('leadTime', { valueAsNumber: true })} {...register('leadTime', { valueAsNumber: true })}
@ -465,6 +475,12 @@ export function ProductFormSheet({
/> />
</div> </div>
{/* 공급사 관리(IMK #20) — 수정 모드(상품 확정)에서만. 추가/삭제/유형변경은 즉시 서버 반영. */}
{mode === 'edit' && product && <ItemSuppliersManager itemId={product.item_id} />}
{/* 회사 커스텀 필드 — companies.settings.item_fields 정의대로 렌더, items.custom 에 저장 */}
<CustomFieldInputs fields={itemFields} state={customValues} title="회사 추가 항목" />
{/* Drag-and-Drop Image Dropzone */} {/* Drag-and-Drop Image Dropzone */}
<Controller <Controller
control={control} control={control}

View File

@ -2,6 +2,7 @@ import { Image as ImageIcon } from 'lucide-react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { DataTable } from '@/components/ui/data-table'; import { DataTable } from '@/components/ui/data-table';
import { TablePagination } from '@/components/ui/table-pagination'; import { TablePagination } from '@/components/ui/table-pagination';
import { useLabels } from '@/features/settings/useCompanySettings';
import { type Product } from '../types'; import { type Product } from '../types';
type ProductTableProps = { type ProductTableProps = {
@ -27,6 +28,7 @@ export function ProductTable({
pageSize, pageSize,
onPageChange, onPageChange,
}: ProductTableProps) { }: ProductTableProps) {
const label = useLabels(); // 회사 설정 용어(카테고리/상품 단가 등)
return ( return (
<DataTable <DataTable
data={data} data={data}
@ -73,7 +75,7 @@ export function ProductTable({
cell: (prod) => prod.code, cell: (prod) => prod.code,
}, },
{ {
header: '카테고리', header: label('category'),
align: 'left', align: 'left',
cell: (prod) => ( cell: (prod) => (
<Badge variant="outline" className="text-[10.5px] border-border text-foreground font-medium bg-muted py-0.5 px-1.5 rounded-full"> <Badge variant="outline" className="text-[10.5px] border-border text-foreground font-medium bg-muted py-0.5 px-1.5 rounded-full">
@ -82,7 +84,21 @@ export function ProductTable({
), ),
}, },
{ {
header: '상품 단가', header: '공급사',
align: 'left',
cell: (prod) => {
const names = prod.supplier_names ?? [];
if (names.length === 0) return <span className="text-muted-foreground">-</span>;
return (
<span title={names.join(', ')} className="whitespace-nowrap">
{names[0]}
{names.length > 1 && <span className="text-muted-foreground"> {names.length - 1}</span>}
</span>
);
},
},
{
header: label('item.price'),
align: 'right', align: 'right',
cellClassName: 'font-mono font-bold text-foreground', cellClassName: 'font-mono font-bold text-foreground',
cell: (prod) => `${(prod.price || 0).toLocaleString()}`, cell: (prod) => `${(prod.price || 0).toLocaleString()}`,

View File

@ -0,0 +1,46 @@
import { useQueryClient } from '@tanstack/react-query';
import {
useListItemSupplyTypes,
createSupplierItem,
updateSupplyType,
deleteSupplierItem,
getListItemSupplyTypesQueryKey,
} from '@/api/generated/supplier-item/supplier-item';
import type { ItemSupplyType } from '@/api/generated/model/itemSupplyType';
// 상품 취급 공급사(매핑) 서버 데이터 + CRUD — useSupplierItems(협력사측)의 상품측 미러.
// 같은 partner.supplier_items 매핑을 상품 상세(ProductFormSheet)에서 편집한다.
// itemId 가 없으면(신규 등록 폼) 쿼리는 비활성.
export function useItemSuppliers(itemId: string | undefined) {
const queryClient = useQueryClient();
const listQuery = useListItemSupplyTypes(itemId ?? '', { query: { enabled: !!itemId } });
const refresh = () =>
itemId
? queryClient.invalidateQueries({ queryKey: getListItemSupplyTypesQueryKey(itemId) })
: Promise.resolve();
const suppliers: ItemSupplyType[] = listQuery.data?.suppliers ?? [];
const addSupplier = async (supplierId: string, supplyType: number) => {
if (!itemId) 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 removeSupplier = async (supplierItemId: string) => {
await deleteSupplierItem(supplierItemId);
await refresh();
};
return { suppliers, isLoading: listQuery.isLoading, addSupplier, changeType, removeSupplier };
}

View File

@ -1,11 +1,12 @@
import { useState, useMemo } from 'react'; import { useState, useMemo } from 'react';
import { X, PlusSquare, ArrowRight, Loader2, Gavel } from 'lucide-react'; import { X, PlusSquare, ArrowRight, Loader2, Gavel, CheckCheck } from 'lucide-react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { useListItemSupplyTypes } from '@/api/generated/supplier-item/supplier-item'; import { useListItemSupplyTypes } from '@/api/generated/supplier-item/supplier-item';
import { useListItems, useGetItem } from '@/api/generated/item/item'; import { useListItems, useGetItem } from '@/api/generated/item/item';
import { useListSuppliers } from '@/api/generated/supplier/supplier'; import { useListSuppliers } from '@/api/generated/supplier/supplier';
import { useListCards } from '@/api/generated/card/card'; import { useListCards } from '@/api/generated/card/card';
import { mapCardData } from '@/features/cards/types'; import { mapCardData } from '@/features/cards/types';
import { useLabels } from '@/features/settings/useCompanySettings';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Typography, typographyVariants } from '@/components/ui/typography'; import { Typography, typographyVariants } from '@/components/ui/typography';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -125,6 +126,7 @@ export function QuotationCreateModal({
// 선택 상품의 협력사별 공급유형(제조/유통/총판/없음) — 협력사 리스트에 배지로 덧붙인다(리스트 자체는 재조회 안 함). // 선택 상품의 협력사별 공급유형(제조/유통/총판/없음) — 협력사 리스트에 배지로 덧붙인다(리스트 자체는 재조회 안 함).
const supplyTypeQuery = useListItemSupplyTypes(productId, { query: { enabled: !!productId } }); const supplyTypeQuery = useListItemSupplyTypes(productId, { query: { enabled: !!productId } });
const label = useLabels(); // 회사 설정 용어(목표 마진 등)
const supplyTypeBySupplier = useMemo(() => { const supplyTypeBySupplier = useMemo(() => {
const m = new Map<string, number>(); const m = new Map<string, number>();
(supplyTypeQuery.data?.suppliers ?? []).forEach((s) => m.set(s.supplier_id, s.supply_type)); (supplyTypeQuery.data?.suppliers ?? []).forEach((s) => m.set(s.supplier_id, s.supply_type));
@ -232,6 +234,17 @@ export function QuotationCreateModal({
if (row) setCardDetails((m) => new Map(m).set(id, { code: row.code, title: row.title, isWildcard: row.isWildcard })); if (row) setCardDetails((m) => new Map(m).set(id, { code: row.code, title: row.title, isWildcard: row.isWildcard }));
setSelectedCardIds((prev) => (prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id])); setSelectedCardIds((prev) => (prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id]));
}; };
// 일괄 선택(IMK #22) — 현재 목록(검색 결과)의 카드를 전부 담는다. 이미 담긴 카드는 유지.
const selectAllCards = () => {
const rows = cardRows.filter((c) => !c.isWildcard || c.status === 'ACTIVE');
setCardDetails((m) => {
const next = new Map(m);
rows.forEach((r) => next.set(r.id, { code: r.code, title: r.title, isWildcard: r.isWildcard }));
return next;
});
setSelectedCardIds((prev) => [...new Set([...prev, ...rows.map((r) => r.id)])]);
};
const clearAllCards = () => setSelectedCardIds([]);
const handleSubmit = async () => { const handleSubmit = async () => {
if (submitting) return; if (submitting) return;
@ -496,7 +509,7 @@ export function QuotationCreateModal({
{(value) => { {(value) => {
const qs = quotationSettings.find((s) => s.qt_setting_id === value); const qs = quotationSettings.find((s) => s.qt_setting_id === value);
return qs return qs
? `[목표 마진: ${qs.target_margin}] 카드 ${qs.card_use_count}` ? `[${label('target_margin')}: ${qs.target_margin}] 카드 ${qs.card_use_count}`
: ''; : '';
}} }}
</SelectValue> </SelectValue>
@ -504,7 +517,7 @@ export function QuotationCreateModal({
<SelectContent> <SelectContent>
{quotationSettings.map((qs) => ( {quotationSettings.map((qs) => (
<SelectItem key={qs.qt_setting_id} value={qs.qt_setting_id}> <SelectItem key={qs.qt_setting_id} value={qs.qt_setting_id}>
[ : {qs.target_margin}] {qs.card_use_count} [{label('target_margin')}: {qs.target_margin}] {qs.card_use_count}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@ -559,7 +572,19 @@ export function QuotationCreateModal({
{/* Step 4 — 협상카드(1:1 협상 전용, 별도 스텝으로 분리해 과밀 방지) */} {/* Step 4 — 협상카드(1:1 협상 전용, 별도 스텝으로 분리해 과밀 방지) */}
{step === 4 && oneToOne && ( {step === 4 && oneToOne && (
<div className="space-y-2"> <div className="space-y-2">
<Typography as="span" variant="label" className="block"> ()</Typography> <div className="flex items-center justify-between">
<Typography as="span" variant="label" className="block"> ()</Typography>
<div className="flex items-center gap-1.5">
<Button type="button" variant="outline" size="sm" className="h-7 px-2.5 text-[11px] gap-1" onClick={selectAllCards}>
<CheckCheck size={13} />
</Button>
<Button type="button" variant="outline" size="sm" className="h-7 px-2.5 text-[11px] gap-1 text-muted-foreground" onClick={clearAllCards} disabled={selectedCardIds.length === 0}>
<X size={13} />
</Button>
</div>
</div>
<Typography as="span" variant="small" className="block text-[10px] text-muted-foreground"> <Typography as="span" variant="small" className="block text-[10px] text-muted-foreground">
1:1 AI . 1:1 AI .
</Typography> </Typography>

Some files were not shown because too many files have changed in this diff Show More