[feat] negodata: 협력사↔상품 매핑(취급상품·공급유형) + 견적 픽리스트 서버검색 콤보박스
- partner.supplier_items 매핑테이블 신설: supply_type(0없음/1유통/2제조/3총판), 부분 유니크 인덱스(soft-delete 인지). quotations.supplier_type와 의미단위 달라 컬럼명 supply_type. - 백엔드: supplier_item CRUD/service/router(/v1/supplier-item, by-supplier·by-item·create·bulk·update·delete). 소유권 company 스코프. ErrorType.SUPPLIER_ITEM_NOT_FOUND 추가. - DDL: 01-schema/04-alter(날짜접미 리네임) + dev DB 반영. - 프론트(feature): 협력사 엑셀 '취급상품' 컬럼(콤마 상품명→일괄매핑, 이름 미매칭 스킵+리포트, 유형 안받고 없음), 협력사 상세 취급상품 관리(추가/삭제/유형변경 즉시반영), 견적모달 협력사 리스트에 선택상품 공급유형 배지. - 재사용 서버검색 Combobox 신설 → 취급상품·견적상품·협력사초청·협상카드 픽리스트에 적용(size 100 캡 해소, 검색은 서버 ILIKE). 견적 선택상품 파생값은 useGetItem 단건조회로 안정화. - orval 재생성(supplier-item 클라이언트/모델). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ed175c5b65
commit
43dbcbcd96
@ -124,7 +124,19 @@ class suppliers(MainTableMixin, MAIN_BASE):
|
|||||||
manager_name = Column(String(50), nullable=True)
|
manager_name = Column(String(50), nullable=True)
|
||||||
manager_email = Column(String(255), nullable=True)
|
manager_email = Column(String(255), nullable=True)
|
||||||
manager_contact_number = Column(String(20), nullable=True) # ERD 오타(manger) 교정
|
manager_contact_number = Column(String(20), nullable=True) # ERD 오타(manger) 교정
|
||||||
total_revenue = Column(BigInteger, nullable=True) # 총매출액(원)
|
total_revenue = Column(BigInteger, nullable=True) # 총매출액
|
||||||
|
|
||||||
|
|
||||||
|
class supplier_items(MainTableMixin, MAIN_BASE):
|
||||||
|
__tablename__ = "supplier_items"
|
||||||
|
# (supplier_id, item_id) 유일성은 soft-delete 인지 부분 유니크 인덱스(uq_supplier_items, WHERE deleted=FALSE)로 DB에서 보장.
|
||||||
|
__table_args__ = {"schema": "partner"}
|
||||||
|
|
||||||
|
supplier_item_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
supplier_id = Column(UUID(as_uuid=True), nullable=False, index=True) # suppliers.supplier_id
|
||||||
|
item_id = Column(UUID(as_uuid=True), nullable=False, index=True) # items.item_id
|
||||||
|
# SupplierType: 이 협력사가 이 상품을 공급하는 방식(0=없음/1=유통/2=제조/3=총판). quotations.supplier_type 와 값은 같으나 의미 단위가 (협력사,상품)이라 컬럼명은 supply_type.
|
||||||
|
supply_type = Column(SmallInteger, nullable=False, server_default=text("0"), default=0)
|
||||||
|
|
||||||
|
|
||||||
class nego_cards(MainTableMixin, MAIN_BASE):
|
class nego_cards(MainTableMixin, MAIN_BASE):
|
||||||
|
|||||||
@ -57,6 +57,7 @@ class ErrorType(Enum):
|
|||||||
# 협력사 관련 에러
|
# 협력사 관련 에러
|
||||||
SUPPLIER_NOT_FOUND = 1400
|
SUPPLIER_NOT_FOUND = 1400
|
||||||
SUPPLIER_CODE_DUPLICATE = auto()
|
SUPPLIER_CODE_DUPLICATE = auto()
|
||||||
|
SUPPLIER_ITEM_NOT_FOUND = auto() # 협력사-상품 매핑 미존재
|
||||||
|
|
||||||
# 견적 관련 에러
|
# 견적 관련 에러
|
||||||
QUOTATION_NOT_FOUND = 1500
|
QUOTATION_NOT_FOUND = 1500
|
||||||
|
|||||||
175
negodata/backend/crud/supplier_item_crud.py
Normal file
175
negodata/backend/crud/supplier_item_crud.py
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
|
from sqlalchemy import select, func, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
|
from common.database.model.models import supplier_items, items
|
||||||
|
from common.enums import ErrorType
|
||||||
|
from common.logger import LOG
|
||||||
|
from common.utils.gtime import GTime
|
||||||
|
|
||||||
|
|
||||||
|
# 협력사-상품 매핑 CRUD. 스코프는 상위(협력사/상품)가 company_id 로 이미 걸린다.
|
||||||
|
class ISupplierItemCRUD(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def list_by_supplier(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, list]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_by_item(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, list]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_by_id(self, cdb: AsyncSession, supplier_item_id) -> Tuple[ErrorType, supplier_items]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def existing_item_ids(self, cdb: AsyncSession, supplier_id, item_ids: list) -> Tuple[ErrorType, list]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def find_items_by_names(self, cdb: AsyncSession, company_id, names: list) -> Tuple[ErrorType, list]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def add_many(self, cdb: AsyncSession, mappings: list) -> ErrorType:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def update_type(self, cdb: AsyncSession, supplier_item_id, supply_type: int) -> ErrorType:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def soft_delete(self, cdb: AsyncSession, supplier_item_id) -> ErrorType:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SupplierItemCRUD(ISupplierItemCRUD):
|
||||||
|
async def list_by_supplier(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, list]:
|
||||||
|
# 협력사 상세용: 매핑 + 상품명/코드 조인. Row(supplier_item_id, item_id, name, code, supply_type)
|
||||||
|
try:
|
||||||
|
query = (
|
||||||
|
select(
|
||||||
|
supplier_items.supplier_item_id,
|
||||||
|
supplier_items.item_id,
|
||||||
|
items.name,
|
||||||
|
items.code,
|
||||||
|
supplier_items.supply_type,
|
||||||
|
)
|
||||||
|
.join(items, items.item_id == supplier_items.item_id)
|
||||||
|
.where(
|
||||||
|
supplier_items.supplier_id == supplier_id,
|
||||||
|
supplier_items.deleted == False, # noqa: E712
|
||||||
|
items.deleted == False, # noqa: E712
|
||||||
|
)
|
||||||
|
.order_by(supplier_items.created_at.desc())
|
||||||
|
)
|
||||||
|
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
return err_type, []
|
||||||
|
return ErrorType.SUCCESS, list(rows)
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(ex)
|
||||||
|
return ErrorType.DB_RUN_FAILED, []
|
||||||
|
|
||||||
|
async def list_by_item(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, list]:
|
||||||
|
# 견적생성 모달용: 이 상품을 취급하는 협력사별 공급유형. Row(supplier_id, supply_type)
|
||||||
|
try:
|
||||||
|
query = select(supplier_items.supplier_id, supplier_items.supply_type).where(
|
||||||
|
supplier_items.item_id == item_id,
|
||||||
|
supplier_items.deleted == False, # noqa: E712
|
||||||
|
)
|
||||||
|
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
return err_type, []
|
||||||
|
return ErrorType.SUCCESS, list(rows)
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(ex)
|
||||||
|
return ErrorType.DB_RUN_FAILED, []
|
||||||
|
|
||||||
|
async def get_by_id(self, cdb: AsyncSession, supplier_item_id) -> Tuple[ErrorType, supplier_items]:
|
||||||
|
try:
|
||||||
|
query = select(supplier_items).where(
|
||||||
|
supplier_items.supplier_item_id == supplier_item_id,
|
||||||
|
supplier_items.deleted == False, # noqa: E712
|
||||||
|
).limit(1)
|
||||||
|
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query)
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
return err_type, None
|
||||||
|
if len(row_list) != 1:
|
||||||
|
return ErrorType.DB_INVALID_KEY, None
|
||||||
|
return ErrorType.SUCCESS, row_list[0]
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(ex)
|
||||||
|
return ErrorType.DB_RUN_FAILED, None
|
||||||
|
|
||||||
|
async def existing_item_ids(self, cdb: AsyncSession, supplier_id, item_ids: list) -> Tuple[ErrorType, list]:
|
||||||
|
# 이 협력사에 이미 매핑된 item_id 들(중복 등록 스킵용).
|
||||||
|
try:
|
||||||
|
if not item_ids:
|
||||||
|
return ErrorType.SUCCESS, []
|
||||||
|
query = select(supplier_items.item_id).where(
|
||||||
|
supplier_items.supplier_id == supplier_id,
|
||||||
|
supplier_items.item_id.in_(item_ids),
|
||||||
|
supplier_items.deleted == False, # noqa: E712
|
||||||
|
)
|
||||||
|
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
return err_type, []
|
||||||
|
return ErrorType.SUCCESS, [r for r in rows if r is not None]
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(ex)
|
||||||
|
return ErrorType.DB_RUN_FAILED, []
|
||||||
|
|
||||||
|
async def find_items_by_names(self, cdb: AsyncSession, company_id, names: list) -> Tuple[ErrorType, list]:
|
||||||
|
# 상품명(정확 일치) → 상품. Row(item_id, name). 이름 중복 상품이 있으면 여럿 반환될 수 있다(서비스에서 첫 매칭 사용).
|
||||||
|
try:
|
||||||
|
if not names:
|
||||||
|
return ErrorType.SUCCESS, []
|
||||||
|
query = select(items.item_id, items.name).where(
|
||||||
|
items.company_id == company_id,
|
||||||
|
items.name.in_(names),
|
||||||
|
items.deleted == False, # noqa: E712
|
||||||
|
)
|
||||||
|
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
return err_type, []
|
||||||
|
return ErrorType.SUCCESS, list(rows)
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(ex)
|
||||||
|
return ErrorType.DB_RUN_FAILED, []
|
||||||
|
|
||||||
|
async def add_many(self, cdb: AsyncSession, mappings: list) -> ErrorType:
|
||||||
|
try:
|
||||||
|
if not mappings:
|
||||||
|
return ErrorType.SUCCESS
|
||||||
|
return await DB_SESSION_MNG.insert(cdb, mappings)
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(ex)
|
||||||
|
return ErrorType.DB_RUN_FAILED
|
||||||
|
|
||||||
|
async def update_type(self, cdb: AsyncSession, supplier_item_id, supply_type: int) -> ErrorType:
|
||||||
|
try:
|
||||||
|
query = (
|
||||||
|
update(supplier_items)
|
||||||
|
.where(supplier_items.supplier_item_id == supplier_item_id)
|
||||||
|
.values(supply_type=supply_type, updated_at=GTime.UTC())
|
||||||
|
)
|
||||||
|
return await DB_SESSION_MNG.add(cdb, query)
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(ex)
|
||||||
|
return ErrorType.DB_RUN_FAILED
|
||||||
|
|
||||||
|
async def soft_delete(self, cdb: AsyncSession, supplier_item_id) -> ErrorType:
|
||||||
|
try:
|
||||||
|
query = (
|
||||||
|
update(supplier_items)
|
||||||
|
.where(supplier_items.supplier_item_id == supplier_item_id)
|
||||||
|
.values(deleted=True, updated_at=GTime.UTC())
|
||||||
|
)
|
||||||
|
return await DB_SESSION_MNG.add(cdb, query)
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(ex)
|
||||||
|
return ErrorType.DB_RUN_FAILED
|
||||||
@ -14,6 +14,7 @@ import router.v1.auth.account
|
|||||||
import router.v1.company.user
|
import router.v1.company.user
|
||||||
import router.v1.item.item
|
import router.v1.item.item
|
||||||
import router.v1.supplier.supplier
|
import router.v1.supplier.supplier
|
||||||
|
import router.v1.supplier_item.supplier_item
|
||||||
import router.v1.card.card
|
import router.v1.card.card
|
||||||
import router.v1.quotation.quotation
|
import router.v1.quotation.quotation
|
||||||
import router.v1.quotation_setting.quotation_setting
|
import router.v1.quotation_setting.quotation_setting
|
||||||
@ -68,6 +69,7 @@ app.include_router(router.v1.auth.account.router)
|
|||||||
app.include_router(router.v1.company.user.router)
|
app.include_router(router.v1.company.user.router)
|
||||||
app.include_router(router.v1.item.item.router)
|
app.include_router(router.v1.item.item.router)
|
||||||
app.include_router(router.v1.supplier.supplier.router)
|
app.include_router(router.v1.supplier.supplier.router)
|
||||||
|
app.include_router(router.v1.supplier_item.supplier_item.router)
|
||||||
app.include_router(router.v1.card.card.router)
|
app.include_router(router.v1.card.card.router)
|
||||||
app.include_router(router.v1.quotation.quotation.router)
|
app.include_router(router.v1.quotation.quotation.router)
|
||||||
app.include_router(router.v1.quotation_setting.quotation_setting.router)
|
app.include_router(router.v1.quotation_setting.quotation_setting.router)
|
||||||
|
|||||||
60
negodata/backend/router/v1/supplier_item/protocol.py
Normal file
60
negodata/backend/router/v1/supplier_item/protocol.py
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import ConfigDict
|
||||||
|
|
||||||
|
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
|
||||||
|
|
||||||
|
|
||||||
|
class SupplierItemProtocol(WebPacketProtocol):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Req_CreateSupplierItem(SupplierItemProtocol):
|
||||||
|
supplier_id: uuid.UUID
|
||||||
|
item_id: uuid.UUID
|
||||||
|
supply_type: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class Req_UpdateSupplyType(SupplierItemProtocol):
|
||||||
|
supply_type: int
|
||||||
|
|
||||||
|
|
||||||
|
class Req_BulkMapByNames(SupplierItemProtocol):
|
||||||
|
names: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
class SupplierItemData(WebPacketProtocol):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
supplier_item_id: uuid.UUID
|
||||||
|
item_id: uuid.UUID
|
||||||
|
item_name: str
|
||||||
|
item_code: Optional[str] = None
|
||||||
|
supply_type: int
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ItemSupplyType(WebPacketProtocol):
|
||||||
|
supplier_id: uuid.UUID
|
||||||
|
supply_type: int
|
||||||
|
|
||||||
|
|
||||||
|
class Res_SupplierItem(Res_WebPacketProtocol):
|
||||||
|
supplier_item: Optional[SupplierItemData] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Res_SupplierItemList(Res_WebPacketProtocol):
|
||||||
|
supplier_items: list[SupplierItemData] = []
|
||||||
|
|
||||||
|
|
||||||
|
class Res_ItemSupplyTypeList(Res_WebPacketProtocol):
|
||||||
|
suppliers: list[ItemSupplyType] = []
|
||||||
|
|
||||||
|
|
||||||
|
class Res_BulkMapByNames(Res_WebPacketProtocol):
|
||||||
|
created_count: int = 0
|
||||||
|
skipped_count: int = 0 # 이미 매핑돼 있어 건너뛴 상품 수
|
||||||
|
unmatched: list[str] = [] # 회사 상품 목록에 이름이 없어 매핑 못한 입력명들
|
||||||
61
negodata/backend/router/v1/supplier_item/supplier_item.py
Normal file
61
negodata/backend/router/v1/supplier_item/supplier_item.py
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from common.models.gmodel import Res_WebPacketProtocol, UserInfo
|
||||||
|
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
|
||||||
|
from services.supplier_item_service import SupplierItemService
|
||||||
|
from .protocol import (
|
||||||
|
Req_BulkMapByNames,
|
||||||
|
Req_CreateSupplierItem,
|
||||||
|
Req_UpdateSupplyType,
|
||||||
|
Res_BulkMapByNames,
|
||||||
|
Res_ItemSupplyTypeList,
|
||||||
|
Res_SupplierItem,
|
||||||
|
Res_SupplierItemList,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 협력사-상품 매핑 라우터. company_id 스코프는 상위 협력사/상품 소유권으로 확인.
|
||||||
|
router = APIRouter(prefix="/v1/supplier-item", tags=["SupplierItem"], responses={404: {"description": "Not found"}})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(path="/by-supplier/{supplier_id}", response_model=Res_SupplierItemList, summary="협력사 취급상품 목록")
|
||||||
|
async def list_supplier_items(
|
||||||
|
supplier_id: UUID, service: SupplierItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
|
||||||
|
):
|
||||||
|
return RemoveNoneResponse(await service.list_by_supplier(user_info.company_id, str(supplier_id)))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(path="/by-item/{item_id}", response_model=Res_ItemSupplyTypeList, summary="상품 취급 협력사 공급유형 목록")
|
||||||
|
async def list_item_supply_types(
|
||||||
|
item_id: UUID, service: SupplierItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
|
||||||
|
):
|
||||||
|
return RemoveNoneResponse(await service.list_by_item(user_info.company_id, str(item_id)))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(path="/create", response_model=Res_SupplierItem, summary="취급상품 매핑 추가")
|
||||||
|
async def create_supplier_item(
|
||||||
|
req: Req_CreateSupplierItem, service: SupplierItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
|
||||||
|
):
|
||||||
|
return RemoveNoneResponse(await service.create(user_info.company_id, req))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(path="/by-supplier/{supplier_id}/bulk", response_model=Res_BulkMapByNames, summary="취급상품 이름 일괄 매핑(엑셀 업로드)")
|
||||||
|
async def bulk_map_supplier_items(
|
||||||
|
supplier_id: UUID, req: Req_BulkMapByNames, service: SupplierItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
|
||||||
|
):
|
||||||
|
return RemoveNoneResponse(await service.bulk_map_by_names(user_info.company_id, str(supplier_id), req.names))
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(path="/update/{supplier_item_id}", response_model=Res_WebPacketProtocol, summary="취급상품 공급유형 수정")
|
||||||
|
async def update_supply_type(
|
||||||
|
supplier_item_id: UUID, req: Req_UpdateSupplyType, service: SupplierItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
|
||||||
|
):
|
||||||
|
return RemoveNoneResponse(await service.update_type(user_info.company_id, str(supplier_item_id), req))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(path="/delete/{supplier_item_id}", response_model=Res_WebPacketProtocol, summary="취급상품 매핑 삭제")
|
||||||
|
async def delete_supplier_item(
|
||||||
|
supplier_item_id: UUID, service: SupplierItemService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
|
||||||
|
):
|
||||||
|
return RemoveNoneResponse(await service.delete(user_info.company_id, str(supplier_item_id)))
|
||||||
264
negodata/backend/services/supplier_item_service.py
Normal file
264
negodata/backend/services/supplier_item_service.py
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
|
||||||
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
|
from common.database.model.models import supplier_items
|
||||||
|
from common.enums import DBWRType, ErrorType, SupplierType
|
||||||
|
from common.models.gmodel import Res_WebPacketProtocol
|
||||||
|
from crud.item_crud import IItemCRUD, ItemCRUD
|
||||||
|
from crud.supplier_crud import ISupplierCRUD, SupplierCRUD
|
||||||
|
from crud.supplier_item_crud import ISupplierItemCRUD, SupplierItemCRUD
|
||||||
|
from router.v1.supplier_item.protocol import (
|
||||||
|
ItemSupplyType,
|
||||||
|
Req_CreateSupplierItem,
|
||||||
|
Req_UpdateSupplyType,
|
||||||
|
Res_BulkMapByNames,
|
||||||
|
Res_ItemSupplyTypeList,
|
||||||
|
Res_SupplierItem,
|
||||||
|
Res_SupplierItemList,
|
||||||
|
SupplierItemData,
|
||||||
|
)
|
||||||
|
|
||||||
|
_VALID_SUPPLY_TYPES = {e.value for e in SupplierType}
|
||||||
|
|
||||||
|
|
||||||
|
class SupplierItemService:
|
||||||
|
"""협력사-상품 매핑 로직. 소유권은 상위 협력사/상품의 company_id 로 확인한다(멀티테넌트)."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
supplier_item_crud: ISupplierItemCRUD = Depends(SupplierItemCRUD),
|
||||||
|
supplier_crud: ISupplierCRUD = Depends(SupplierCRUD),
|
||||||
|
item_crud: IItemCRUD = Depends(ItemCRUD),
|
||||||
|
):
|
||||||
|
self.supplier_item_crud = supplier_item_crud
|
||||||
|
self.supplier_crud = supplier_crud
|
||||||
|
self.item_crud = item_crud
|
||||||
|
|
||||||
|
async def _supplier_owned(self, company_uuid: uuid.UUID, supplier_id: uuid.UUID) -> ErrorType:
|
||||||
|
err_type, supplier = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
supplier_items.DBType(),
|
||||||
|
DBWRType.DB_READ.value,
|
||||||
|
lambda s: self.supplier_crud.get_by_id(s, supplier_id),
|
||||||
|
)
|
||||||
|
if err_type != ErrorType.SUCCESS or supplier is None or supplier.company_id != company_uuid:
|
||||||
|
return ErrorType.SUPPLIER_NOT_FOUND
|
||||||
|
return ErrorType.SUCCESS
|
||||||
|
|
||||||
|
async def _item_owned(self, company_uuid: uuid.UUID, item_id: uuid.UUID):
|
||||||
|
err_type, item = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
supplier_items.DBType(),
|
||||||
|
DBWRType.DB_READ.value,
|
||||||
|
lambda s: self.item_crud.get_by_id(s, item_id),
|
||||||
|
)
|
||||||
|
if err_type != ErrorType.SUCCESS or item is None or item.company_id != company_uuid:
|
||||||
|
return ErrorType.ITEM_NOT_FOUND, None
|
||||||
|
return ErrorType.SUCCESS, item
|
||||||
|
|
||||||
|
async def _fetch_owned_mapping(self, company_uuid: uuid.UUID, supplier_item_id: uuid.UUID):
|
||||||
|
"""매핑 조회 + 소유 협력사 확인. (ErrorType, mapping|None) 반환."""
|
||||||
|
err_type, mapping = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
supplier_items.DBType(),
|
||||||
|
DBWRType.DB_READ.value,
|
||||||
|
lambda s: self.supplier_item_crud.get_by_id(s, supplier_item_id),
|
||||||
|
)
|
||||||
|
if err_type != ErrorType.SUCCESS or mapping is None:
|
||||||
|
return ErrorType.SUPPLIER_ITEM_NOT_FOUND, None
|
||||||
|
own_err = await self._supplier_owned(company_uuid, mapping.supplier_id)
|
||||||
|
if own_err != ErrorType.SUCCESS:
|
||||||
|
return ErrorType.SUPPLIER_ITEM_NOT_FOUND, None
|
||||||
|
return ErrorType.SUCCESS, mapping
|
||||||
|
|
||||||
|
async def list_by_supplier(self, company_id: str, supplier_id: str) -> Res_SupplierItemList:
|
||||||
|
res = Res_SupplierItemList()
|
||||||
|
own_err = await self._supplier_owned(uuid.UUID(company_id), uuid.UUID(supplier_id))
|
||||||
|
if own_err != ErrorType.SUCCESS:
|
||||||
|
res.result.SetResult(own_err)
|
||||||
|
return res
|
||||||
|
|
||||||
|
err_type, rows = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
supplier_items.DBType(),
|
||||||
|
DBWRType.DB_READ.value,
|
||||||
|
lambda s: self.supplier_item_crud.list_by_supplier(s, uuid.UUID(supplier_id)),
|
||||||
|
)
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
res.result.SetResult(err_type)
|
||||||
|
return res
|
||||||
|
# Row(supplier_item_id, item_id, name, code, supply_type)
|
||||||
|
res.supplier_items = [
|
||||||
|
SupplierItemData(
|
||||||
|
supplier_item_id=r[0], item_id=r[1], item_name=r[2], item_code=r[3], supply_type=r[4]
|
||||||
|
)
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
return res
|
||||||
|
|
||||||
|
async def list_by_item(self, company_id: str, item_id: str) -> Res_ItemSupplyTypeList:
|
||||||
|
res = Res_ItemSupplyTypeList()
|
||||||
|
own_err, _ = await self._item_owned(uuid.UUID(company_id), uuid.UUID(item_id))
|
||||||
|
if own_err != ErrorType.SUCCESS:
|
||||||
|
res.result.SetResult(own_err)
|
||||||
|
return res
|
||||||
|
|
||||||
|
err_type, rows = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
supplier_items.DBType(),
|
||||||
|
DBWRType.DB_READ.value,
|
||||||
|
lambda s: self.supplier_item_crud.list_by_item(s, uuid.UUID(item_id)),
|
||||||
|
)
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
res.result.SetResult(err_type)
|
||||||
|
return res
|
||||||
|
# Row(supplier_id, supply_type)
|
||||||
|
res.suppliers = [ItemSupplyType(supplier_id=r[0], supply_type=r[1]) for r in rows]
|
||||||
|
return res
|
||||||
|
|
||||||
|
async def create(self, company_id: str, req: Req_CreateSupplierItem) -> Res_SupplierItem:
|
||||||
|
res = Res_SupplierItem()
|
||||||
|
company_uuid = uuid.UUID(company_id)
|
||||||
|
|
||||||
|
if req.supply_type not in _VALID_SUPPLY_TYPES:
|
||||||
|
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
|
||||||
|
return res
|
||||||
|
|
||||||
|
own_err = await self._supplier_owned(company_uuid, req.supplier_id)
|
||||||
|
if own_err != ErrorType.SUCCESS:
|
||||||
|
res.result.SetResult(own_err)
|
||||||
|
return res
|
||||||
|
item_err, item = await self._item_owned(company_uuid, req.item_id)
|
||||||
|
if item_err != ErrorType.SUCCESS:
|
||||||
|
res.result.SetResult(item_err)
|
||||||
|
return res
|
||||||
|
|
||||||
|
# 활성 중복 매핑 차단(부분 유니크 인덱스와 이중 방어).
|
||||||
|
dup_err, existing = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
supplier_items.DBType(),
|
||||||
|
DBWRType.DB_READ.value,
|
||||||
|
lambda s: self.supplier_item_crud.existing_item_ids(s, req.supplier_id, [req.item_id]),
|
||||||
|
)
|
||||||
|
if dup_err != ErrorType.SUCCESS:
|
||||||
|
res.result.SetResult(dup_err)
|
||||||
|
return res
|
||||||
|
if existing:
|
||||||
|
res.result.SetResult(ErrorType.DB_ALREADY_SAME_KEY)
|
||||||
|
return res
|
||||||
|
|
||||||
|
mapping = supplier_items(supplier_id=req.supplier_id, item_id=req.item_id, supply_type=req.supply_type)
|
||||||
|
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||||
|
[supplier_items.DBType()],
|
||||||
|
[lambda s: self.supplier_item_crud.add_many(s, [mapping])],
|
||||||
|
)
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
res.result.SetResult(err_type)
|
||||||
|
return res
|
||||||
|
|
||||||
|
res.supplier_item = SupplierItemData(
|
||||||
|
supplier_item_id=mapping.supplier_item_id,
|
||||||
|
item_id=item.item_id,
|
||||||
|
item_name=item.name,
|
||||||
|
item_code=item.code,
|
||||||
|
supply_type=req.supply_type,
|
||||||
|
)
|
||||||
|
return res
|
||||||
|
|
||||||
|
async def update_type(self, company_id: str, supplier_item_id: str, req: Req_UpdateSupplyType) -> Res_WebPacketProtocol:
|
||||||
|
res = Res_WebPacketProtocol()
|
||||||
|
if req.supply_type not in _VALID_SUPPLY_TYPES:
|
||||||
|
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
|
||||||
|
return res
|
||||||
|
|
||||||
|
err_type, mapping = await self._fetch_owned_mapping(uuid.UUID(company_id), uuid.UUID(supplier_item_id))
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
res.result.SetResult(err_type)
|
||||||
|
return res
|
||||||
|
|
||||||
|
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||||
|
[supplier_items.DBType()],
|
||||||
|
[lambda s: self.supplier_item_crud.update_type(s, mapping.supplier_item_id, req.supply_type)],
|
||||||
|
)
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
res.result.SetResult(err_type)
|
||||||
|
return res
|
||||||
|
|
||||||
|
async def delete(self, company_id: str, supplier_item_id: str) -> Res_WebPacketProtocol:
|
||||||
|
res = Res_WebPacketProtocol()
|
||||||
|
err_type, mapping = await self._fetch_owned_mapping(uuid.UUID(company_id), uuid.UUID(supplier_item_id))
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
res.result.SetResult(err_type)
|
||||||
|
return res
|
||||||
|
|
||||||
|
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||||
|
[supplier_items.DBType()],
|
||||||
|
[lambda s: self.supplier_item_crud.soft_delete(s, mapping.supplier_item_id)],
|
||||||
|
)
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
res.result.SetResult(err_type)
|
||||||
|
return res
|
||||||
|
|
||||||
|
async def bulk_map_by_names(self, company_id: str, supplier_id: str, names: list) -> Res_BulkMapByNames:
|
||||||
|
"""엑셀 취급상품 업로드용: 상품명 리스트 → 매핑 생성. 미매칭명은 스킵 후 리포트, 이미 매핑된 상품도 스킵.
|
||||||
|
업로드는 공급유형을 받지 않으므로 전부 없음(0)으로 들어간다(상세에서 편집)."""
|
||||||
|
res = Res_BulkMapByNames()
|
||||||
|
company_uuid = uuid.UUID(company_id)
|
||||||
|
supplier_uuid = uuid.UUID(supplier_id)
|
||||||
|
|
||||||
|
own_err = await self._supplier_owned(company_uuid, supplier_uuid)
|
||||||
|
if own_err != ErrorType.SUCCESS:
|
||||||
|
res.result.SetResult(own_err)
|
||||||
|
return res
|
||||||
|
|
||||||
|
# 입력 정규화: 공백 제거·빈값 제거·중복 제거(원문 순서 유지).
|
||||||
|
seen = set()
|
||||||
|
clean_names = []
|
||||||
|
for raw in names:
|
||||||
|
n = (raw or "").strip()
|
||||||
|
if n and n not in seen:
|
||||||
|
seen.add(n)
|
||||||
|
clean_names.append(n)
|
||||||
|
if not clean_names:
|
||||||
|
return res
|
||||||
|
|
||||||
|
# 이름 → 상품 해석(정확 일치). 이름 중복 상품은 첫 매칭만 사용.
|
||||||
|
find_err, rows = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
supplier_items.DBType(),
|
||||||
|
DBWRType.DB_READ.value,
|
||||||
|
lambda s: self.supplier_item_crud.find_items_by_names(s, company_uuid, clean_names),
|
||||||
|
)
|
||||||
|
if find_err != ErrorType.SUCCESS:
|
||||||
|
res.result.SetResult(find_err)
|
||||||
|
return res
|
||||||
|
name_to_item = {}
|
||||||
|
for r in rows: # Row(item_id, name)
|
||||||
|
if r[1] not in name_to_item:
|
||||||
|
name_to_item[r[1]] = r[0]
|
||||||
|
|
||||||
|
res.unmatched = [n for n in clean_names if n not in name_to_item]
|
||||||
|
matched_item_ids = [name_to_item[n] for n in clean_names if n in name_to_item]
|
||||||
|
if not matched_item_ids:
|
||||||
|
return res
|
||||||
|
|
||||||
|
# 이미 이 협력사에 매핑된 상품 제외.
|
||||||
|
exist_err, already = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
supplier_items.DBType(),
|
||||||
|
DBWRType.DB_READ.value,
|
||||||
|
lambda s: self.supplier_item_crud.existing_item_ids(s, supplier_uuid, matched_item_ids),
|
||||||
|
)
|
||||||
|
if exist_err != ErrorType.SUCCESS:
|
||||||
|
res.result.SetResult(exist_err)
|
||||||
|
return res
|
||||||
|
already_set = set(already)
|
||||||
|
to_create = [iid for iid in matched_item_ids if iid not in already_set]
|
||||||
|
res.skipped_count = len(matched_item_ids) - len(to_create)
|
||||||
|
if not to_create:
|
||||||
|
return res
|
||||||
|
|
||||||
|
mappings = [supplier_items(supplier_id=supplier_uuid, item_id=iid, supply_type=0) for iid in to_create]
|
||||||
|
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||||
|
[supplier_items.DBType()],
|
||||||
|
[lambda s: self.supplier_item_crud.add_many(s, mappings)],
|
||||||
|
)
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
res.result.SetResult(err_type)
|
||||||
|
return res
|
||||||
|
res.created_count = len(to_create)
|
||||||
|
return res
|
||||||
@ -69,6 +69,7 @@ export * from './itemDataSellingPrice';
|
|||||||
export * from './itemDataSpec';
|
export * from './itemDataSpec';
|
||||||
export * from './itemDataUpdatedAt';
|
export * from './itemDataUpdatedAt';
|
||||||
export * from './itemDataVatYn';
|
export * from './itemDataVatYn';
|
||||||
|
export * from './itemSupplyType';
|
||||||
export * from './listCardsParams';
|
export * from './listCardsParams';
|
||||||
export * from './listItemsParams';
|
export * from './listItemsParams';
|
||||||
export * from './listNotificationsParams';
|
export * from './listNotificationsParams';
|
||||||
@ -119,6 +120,7 @@ export * from './quotationSettingDataUpdatedAt';
|
|||||||
export * from './quotationSettingDataUserId';
|
export * from './quotationSettingDataUserId';
|
||||||
export * from './quotationStatus';
|
export * from './quotationStatus';
|
||||||
export * from './quotationType';
|
export * from './quotationType';
|
||||||
|
export * from './reqBulkMapByNames';
|
||||||
export * from './reqCheckCodes';
|
export * from './reqCheckCodes';
|
||||||
export * from './reqCreateCard';
|
export * from './reqCreateCard';
|
||||||
export * from './reqCreateCardCondition';
|
export * from './reqCreateCardCondition';
|
||||||
@ -160,6 +162,7 @@ export * from './reqCreateQuotationSupplierType';
|
|||||||
export * from './reqCreateQuotationVersionId';
|
export * from './reqCreateQuotationVersionId';
|
||||||
export * from './reqCreateSupplier';
|
export * from './reqCreateSupplier';
|
||||||
export * from './reqCreateSupplierCode';
|
export * from './reqCreateSupplierCode';
|
||||||
|
export * from './reqCreateSupplierItem';
|
||||||
export * from './reqCreateSupplierManagerContactNumber';
|
export * from './reqCreateSupplierManagerContactNumber';
|
||||||
export * from './reqCreateSupplierManagerEmail';
|
export * from './reqCreateSupplierManagerEmail';
|
||||||
export * from './reqCreateSupplierManagerName';
|
export * from './reqCreateSupplierManagerName';
|
||||||
@ -217,6 +220,9 @@ export * from './reqUpdateSupplierManagerEmail';
|
|||||||
export * from './reqUpdateSupplierManagerName';
|
export * from './reqUpdateSupplierManagerName';
|
||||||
export * from './reqUpdateSupplierName';
|
export * from './reqUpdateSupplierName';
|
||||||
export * from './reqUpdateSupplierTotalRevenue';
|
export * from './reqUpdateSupplierTotalRevenue';
|
||||||
|
export * from './reqUpdateSupplyType';
|
||||||
|
export * from './resBulkMapByNames';
|
||||||
|
export * from './resBulkMapByNamesMsg';
|
||||||
export * from './resCard';
|
export * from './resCard';
|
||||||
export * from './resCardCard';
|
export * from './resCardCard';
|
||||||
export * from './resCardList';
|
export * from './resCardList';
|
||||||
@ -258,6 +264,8 @@ export * from './resItemItem';
|
|||||||
export * from './resItemList';
|
export * from './resItemList';
|
||||||
export * from './resItemListMsg';
|
export * from './resItemListMsg';
|
||||||
export * from './resItemMsg';
|
export * from './resItemMsg';
|
||||||
|
export * from './resItemSupplyTypeList';
|
||||||
|
export * from './resItemSupplyTypeListMsg';
|
||||||
export * from './resLastSupplierType';
|
export * from './resLastSupplierType';
|
||||||
export * from './resLastSupplierTypeMsg';
|
export * from './resLastSupplierTypeMsg';
|
||||||
export * from './resLastSupplierTypeQtNumber';
|
export * from './resLastSupplierTypeQtNumber';
|
||||||
@ -312,6 +320,11 @@ export * from './resSessionChat';
|
|||||||
export * from './resSessionChatMsg';
|
export * from './resSessionChatMsg';
|
||||||
export * from './resSessionChatSessionId';
|
export * from './resSessionChatSessionId';
|
||||||
export * from './resSupplier';
|
export * from './resSupplier';
|
||||||
|
export * from './resSupplierItem';
|
||||||
|
export * from './resSupplierItemList';
|
||||||
|
export * from './resSupplierItemListMsg';
|
||||||
|
export * from './resSupplierItemMsg';
|
||||||
|
export * from './resSupplierItemSupplierItem';
|
||||||
export * from './resSupplierList';
|
export * from './resSupplierList';
|
||||||
export * from './resSupplierListMsg';
|
export * from './resSupplierListMsg';
|
||||||
export * from './resSupplierMsg';
|
export * from './resSupplierMsg';
|
||||||
@ -324,6 +337,8 @@ export * from './resTargetBreakdownMdPrice';
|
|||||||
export * from './resTargetBreakdownMsg';
|
export * from './resTargetBreakdownMsg';
|
||||||
export * from './resTargetBreakdownPurchase';
|
export * from './resTargetBreakdownPurchase';
|
||||||
export * from './resTargetBreakdownSelling';
|
export * from './resTargetBreakdownSelling';
|
||||||
|
export * from './resWebPacketProtocol';
|
||||||
|
export * from './resWebPacketProtocolMsg';
|
||||||
export * from './sessionData';
|
export * from './sessionData';
|
||||||
export * from './sessionDataAnchoringPrice';
|
export * from './sessionDataAnchoringPrice';
|
||||||
export * from './sessionDataBidAt';
|
export * from './sessionDataBidAt';
|
||||||
@ -341,6 +356,10 @@ export * from './supplierDataManagerEmail';
|
|||||||
export * from './supplierDataManagerName';
|
export * from './supplierDataManagerName';
|
||||||
export * from './supplierDataTotalRevenue';
|
export * from './supplierDataTotalRevenue';
|
||||||
export * from './supplierDataUpdatedAt';
|
export * from './supplierDataUpdatedAt';
|
||||||
|
export * from './supplierItemData';
|
||||||
|
export * from './supplierItemDataCreatedAt';
|
||||||
|
export * from './supplierItemDataItemCode';
|
||||||
|
export * from './supplierItemDataUpdatedAt';
|
||||||
export * from './supplierType';
|
export * from './supplierType';
|
||||||
export * from './targetCandidate';
|
export * from './targetCandidate';
|
||||||
export * from './userRole';
|
export * from './userRole';
|
||||||
|
|||||||
11
negodata/front/src/api/generated/model/itemSupplyType.ts
Normal file
11
negodata/front/src/api/generated/model/itemSupplyType.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ItemSupplyType {
|
||||||
|
supplier_id: string;
|
||||||
|
supply_type: number;
|
||||||
|
}
|
||||||
10
negodata/front/src/api/generated/model/reqBulkMapByNames.ts
Normal file
10
negodata/front/src/api/generated/model/reqBulkMapByNames.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ReqBulkMapByNames {
|
||||||
|
names?: string[];
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ReqCreateSupplierItem {
|
||||||
|
supplier_id: string;
|
||||||
|
item_id: string;
|
||||||
|
supply_type?: number;
|
||||||
|
}
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ReqUpdateSupplyType {
|
||||||
|
supply_type: number;
|
||||||
|
}
|
||||||
16
negodata/front/src/api/generated/model/resBulkMapByNames.ts
Normal file
16
negodata/front/src/api/generated/model/resBulkMapByNames.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
import type { ErrorInfo } from './errorInfo';
|
||||||
|
import type { ResBulkMapByNamesMsg } from './resBulkMapByNamesMsg';
|
||||||
|
|
||||||
|
export interface ResBulkMapByNames {
|
||||||
|
result?: ErrorInfo;
|
||||||
|
msg?: ResBulkMapByNamesMsg;
|
||||||
|
created_count?: number;
|
||||||
|
skipped_count?: number;
|
||||||
|
unmatched?: string[];
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ResBulkMapByNamesMsg = string | null;
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
import type { ErrorInfo } from './errorInfo';
|
||||||
|
import type { ResItemSupplyTypeListMsg } from './resItemSupplyTypeListMsg';
|
||||||
|
import type { ItemSupplyType } from './itemSupplyType';
|
||||||
|
|
||||||
|
export interface ResItemSupplyTypeList {
|
||||||
|
result?: ErrorInfo;
|
||||||
|
msg?: ResItemSupplyTypeListMsg;
|
||||||
|
suppliers?: ItemSupplyType[];
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ResItemSupplyTypeListMsg = string | null;
|
||||||
15
negodata/front/src/api/generated/model/resSupplierItem.ts
Normal file
15
negodata/front/src/api/generated/model/resSupplierItem.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
import type { ErrorInfo } from './errorInfo';
|
||||||
|
import type { ResSupplierItemMsg } from './resSupplierItemMsg';
|
||||||
|
import type { ResSupplierItemSupplierItem } from './resSupplierItemSupplierItem';
|
||||||
|
|
||||||
|
export interface ResSupplierItem {
|
||||||
|
result?: ErrorInfo;
|
||||||
|
msg?: ResSupplierItemMsg;
|
||||||
|
supplier_item?: ResSupplierItemSupplierItem;
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
import type { ErrorInfo } from './errorInfo';
|
||||||
|
import type { ResSupplierItemListMsg } from './resSupplierItemListMsg';
|
||||||
|
import type { SupplierItemData } from './supplierItemData';
|
||||||
|
|
||||||
|
export interface ResSupplierItemList {
|
||||||
|
result?: ErrorInfo;
|
||||||
|
msg?: ResSupplierItemListMsg;
|
||||||
|
supplier_items?: SupplierItemData[];
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ResSupplierItemListMsg = string | null;
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ResSupplierItemMsg = string | null;
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
import type { SupplierItemData } from './supplierItemData';
|
||||||
|
|
||||||
|
export type ResSupplierItemSupplierItem = SupplierItemData | null;
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
import type { ErrorInfo } from './errorInfo';
|
||||||
|
import type { ResWebPacketProtocolMsg } from './resWebPacketProtocolMsg';
|
||||||
|
|
||||||
|
export interface ResWebPacketProtocol {
|
||||||
|
result?: ErrorInfo;
|
||||||
|
msg?: ResWebPacketProtocolMsg;
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ResWebPacketProtocolMsg = string | null;
|
||||||
19
negodata/front/src/api/generated/model/supplierItemData.ts
Normal file
19
negodata/front/src/api/generated/model/supplierItemData.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
import type { SupplierItemDataItemCode } from './supplierItemDataItemCode';
|
||||||
|
import type { SupplierItemDataCreatedAt } from './supplierItemDataCreatedAt';
|
||||||
|
import type { SupplierItemDataUpdatedAt } from './supplierItemDataUpdatedAt';
|
||||||
|
|
||||||
|
export interface SupplierItemData {
|
||||||
|
supplier_item_id: string;
|
||||||
|
item_id: string;
|
||||||
|
item_name: string;
|
||||||
|
item_code?: SupplierItemDataItemCode;
|
||||||
|
supply_type: number;
|
||||||
|
created_at?: SupplierItemDataCreatedAt;
|
||||||
|
updated_at?: SupplierItemDataUpdatedAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SupplierItemDataCreatedAt = string | null;
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SupplierItemDataItemCode = string | null;
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SupplierItemDataUpdatedAt = string | null;
|
||||||
483
negodata/front/src/api/generated/supplier-item/supplier-item.ts
Normal file
483
negodata/front/src/api/generated/supplier-item/supplier-item.ts
Normal file
@ -0,0 +1,483 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Negodata Api Server
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
useMutation,
|
||||||
|
useQuery
|
||||||
|
} from '@tanstack/react-query';
|
||||||
|
import type {
|
||||||
|
DataTag,
|
||||||
|
DefinedInitialDataOptions,
|
||||||
|
DefinedUseQueryResult,
|
||||||
|
MutationFunction,
|
||||||
|
QueryClient,
|
||||||
|
QueryFunction,
|
||||||
|
QueryKey,
|
||||||
|
UndefinedInitialDataOptions,
|
||||||
|
UseMutationOptions,
|
||||||
|
UseMutationResult,
|
||||||
|
UseQueryOptions,
|
||||||
|
UseQueryResult
|
||||||
|
} from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
HTTPValidationError,
|
||||||
|
ReqBulkMapByNames,
|
||||||
|
ReqCreateSupplierItem,
|
||||||
|
ReqUpdateSupplyType,
|
||||||
|
ResBulkMapByNames,
|
||||||
|
ResItemSupplyTypeList,
|
||||||
|
ResSupplierItem,
|
||||||
|
ResSupplierItemList,
|
||||||
|
ResWebPacketProtocol
|
||||||
|
} from '.././model';
|
||||||
|
|
||||||
|
import { customFetch } from '../../mutator/custom-fetch';
|
||||||
|
|
||||||
|
|
||||||
|
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary 협력사 취급상품 목록
|
||||||
|
*/
|
||||||
|
export const listSupplierItems = (
|
||||||
|
supplierId: string,
|
||||||
|
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
|
||||||
|
) => {
|
||||||
|
|
||||||
|
|
||||||
|
return customFetch<ResSupplierItemList>(
|
||||||
|
{url: `/v1/supplier-item/by-supplier/${supplierId}`, method: 'GET', signal
|
||||||
|
},
|
||||||
|
options);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getListSupplierItemsQueryKey = (supplierId?: string,) => {
|
||||||
|
return [
|
||||||
|
`/v1/supplier-item/by-supplier/${supplierId}`
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const getListSupplierItemsQueryOptions = <TData = Awaited<ReturnType<typeof listSupplierItems>>, TError = void | HTTPValidationError>(supplierId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listSupplierItems>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
) => {
|
||||||
|
|
||||||
|
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||||
|
|
||||||
|
const queryKey = queryOptions?.queryKey ?? getListSupplierItemsQueryKey(supplierId);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const queryFn: QueryFunction<Awaited<ReturnType<typeof listSupplierItems>>> = ({ signal }) => listSupplierItems(supplierId, requestOptions, signal);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { queryKey, queryFn, enabled: !!(supplierId), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listSupplierItems>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListSupplierItemsQueryResult = NonNullable<Awaited<ReturnType<typeof listSupplierItems>>>
|
||||||
|
export type ListSupplierItemsQueryError = void | HTTPValidationError
|
||||||
|
|
||||||
|
|
||||||
|
export function useListSupplierItems<TData = Awaited<ReturnType<typeof listSupplierItems>>, TError = void | HTTPValidationError>(
|
||||||
|
supplierId: string, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof listSupplierItems>>, TError, TData>> & Pick<
|
||||||
|
DefinedInitialDataOptions<
|
||||||
|
Awaited<ReturnType<typeof listSupplierItems>>,
|
||||||
|
TError,
|
||||||
|
Awaited<ReturnType<typeof listSupplierItems>>
|
||||||
|
> , 'initialData'
|
||||||
|
>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
, queryClient?: QueryClient
|
||||||
|
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||||
|
export function useListSupplierItems<TData = Awaited<ReturnType<typeof listSupplierItems>>, TError = void | HTTPValidationError>(
|
||||||
|
supplierId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listSupplierItems>>, TError, TData>> & Pick<
|
||||||
|
UndefinedInitialDataOptions<
|
||||||
|
Awaited<ReturnType<typeof listSupplierItems>>,
|
||||||
|
TError,
|
||||||
|
Awaited<ReturnType<typeof listSupplierItems>>
|
||||||
|
> , 'initialData'
|
||||||
|
>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
, queryClient?: QueryClient
|
||||||
|
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||||
|
export function useListSupplierItems<TData = Awaited<ReturnType<typeof listSupplierItems>>, TError = void | HTTPValidationError>(
|
||||||
|
supplierId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listSupplierItems>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
, queryClient?: QueryClient
|
||||||
|
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||||
|
/**
|
||||||
|
* @summary 협력사 취급상품 목록
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function useListSupplierItems<TData = Awaited<ReturnType<typeof listSupplierItems>>, TError = void | HTTPValidationError>(
|
||||||
|
supplierId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listSupplierItems>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
, queryClient?: QueryClient
|
||||||
|
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
|
||||||
|
|
||||||
|
const queryOptions = getListSupplierItemsQueryOptions(supplierId,options)
|
||||||
|
|
||||||
|
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||||
|
|
||||||
|
query.queryKey = queryOptions.queryKey ;
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary 상품 취급 협력사 공급유형 목록
|
||||||
|
*/
|
||||||
|
export const listItemSupplyTypes = (
|
||||||
|
itemId: string,
|
||||||
|
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
|
||||||
|
) => {
|
||||||
|
|
||||||
|
|
||||||
|
return customFetch<ResItemSupplyTypeList>(
|
||||||
|
{url: `/v1/supplier-item/by-item/${itemId}`, method: 'GET', signal
|
||||||
|
},
|
||||||
|
options);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getListItemSupplyTypesQueryKey = (itemId?: string,) => {
|
||||||
|
return [
|
||||||
|
`/v1/supplier-item/by-item/${itemId}`
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const getListItemSupplyTypesQueryOptions = <TData = Awaited<ReturnType<typeof listItemSupplyTypes>>, TError = void | HTTPValidationError>(itemId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listItemSupplyTypes>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
) => {
|
||||||
|
|
||||||
|
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||||
|
|
||||||
|
const queryKey = queryOptions?.queryKey ?? getListItemSupplyTypesQueryKey(itemId);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const queryFn: QueryFunction<Awaited<ReturnType<typeof listItemSupplyTypes>>> = ({ signal }) => listItemSupplyTypes(itemId, requestOptions, signal);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { queryKey, queryFn, enabled: !!(itemId), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listItemSupplyTypes>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListItemSupplyTypesQueryResult = NonNullable<Awaited<ReturnType<typeof listItemSupplyTypes>>>
|
||||||
|
export type ListItemSupplyTypesQueryError = void | HTTPValidationError
|
||||||
|
|
||||||
|
|
||||||
|
export function useListItemSupplyTypes<TData = Awaited<ReturnType<typeof listItemSupplyTypes>>, TError = void | HTTPValidationError>(
|
||||||
|
itemId: string, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof listItemSupplyTypes>>, TError, TData>> & Pick<
|
||||||
|
DefinedInitialDataOptions<
|
||||||
|
Awaited<ReturnType<typeof listItemSupplyTypes>>,
|
||||||
|
TError,
|
||||||
|
Awaited<ReturnType<typeof listItemSupplyTypes>>
|
||||||
|
> , 'initialData'
|
||||||
|
>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
, queryClient?: QueryClient
|
||||||
|
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||||
|
export function useListItemSupplyTypes<TData = Awaited<ReturnType<typeof listItemSupplyTypes>>, TError = void | HTTPValidationError>(
|
||||||
|
itemId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listItemSupplyTypes>>, TError, TData>> & Pick<
|
||||||
|
UndefinedInitialDataOptions<
|
||||||
|
Awaited<ReturnType<typeof listItemSupplyTypes>>,
|
||||||
|
TError,
|
||||||
|
Awaited<ReturnType<typeof listItemSupplyTypes>>
|
||||||
|
> , 'initialData'
|
||||||
|
>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
, queryClient?: QueryClient
|
||||||
|
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||||
|
export function useListItemSupplyTypes<TData = Awaited<ReturnType<typeof listItemSupplyTypes>>, TError = void | HTTPValidationError>(
|
||||||
|
itemId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listItemSupplyTypes>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
, queryClient?: QueryClient
|
||||||
|
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||||
|
/**
|
||||||
|
* @summary 상품 취급 협력사 공급유형 목록
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function useListItemSupplyTypes<TData = Awaited<ReturnType<typeof listItemSupplyTypes>>, TError = void | HTTPValidationError>(
|
||||||
|
itemId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listItemSupplyTypes>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
, queryClient?: QueryClient
|
||||||
|
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
|
||||||
|
|
||||||
|
const queryOptions = getListItemSupplyTypesQueryOptions(itemId,options)
|
||||||
|
|
||||||
|
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||||
|
|
||||||
|
query.queryKey = queryOptions.queryKey ;
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary 취급상품 매핑 추가
|
||||||
|
*/
|
||||||
|
export const createSupplierItem = (
|
||||||
|
reqCreateSupplierItem: ReqCreateSupplierItem,
|
||||||
|
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
|
||||||
|
) => {
|
||||||
|
|
||||||
|
|
||||||
|
return customFetch<ResSupplierItem>(
|
||||||
|
{url: `/v1/supplier-item/create`, method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json', },
|
||||||
|
data: reqCreateSupplierItem, signal
|
||||||
|
},
|
||||||
|
options);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getCreateSupplierItemMutationOptions = <TError = void | HTTPValidationError,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createSupplierItem>>, TError,{data: ReqCreateSupplierItem}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
): UseMutationOptions<Awaited<ReturnType<typeof createSupplierItem>>, TError,{data: ReqCreateSupplierItem}, TContext> => {
|
||||||
|
|
||||||
|
const mutationKey = ['createSupplierItem'];
|
||||||
|
const {mutation: mutationOptions, request: requestOptions} = options ?
|
||||||
|
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
|
||||||
|
options
|
||||||
|
: {...options, mutation: {...options.mutation, mutationKey}}
|
||||||
|
: {mutation: { mutationKey, }, request: undefined};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const mutationFn: MutationFunction<Awaited<ReturnType<typeof createSupplierItem>>, {data: ReqCreateSupplierItem}> = (props) => {
|
||||||
|
const {data} = props ?? {};
|
||||||
|
|
||||||
|
return createSupplierItem(data,requestOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { mutationFn, ...mutationOptions }}
|
||||||
|
|
||||||
|
export type CreateSupplierItemMutationResult = NonNullable<Awaited<ReturnType<typeof createSupplierItem>>>
|
||||||
|
export type CreateSupplierItemMutationBody = ReqCreateSupplierItem
|
||||||
|
export type CreateSupplierItemMutationError = void | HTTPValidationError
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary 취급상품 매핑 추가
|
||||||
|
*/
|
||||||
|
export const useCreateSupplierItem = <TError = void | HTTPValidationError,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createSupplierItem>>, TError,{data: ReqCreateSupplierItem}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
, queryClient?: QueryClient): UseMutationResult<
|
||||||
|
Awaited<ReturnType<typeof createSupplierItem>>,
|
||||||
|
TError,
|
||||||
|
{data: ReqCreateSupplierItem},
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
|
||||||
|
const mutationOptions = getCreateSupplierItemMutationOptions(options);
|
||||||
|
|
||||||
|
return useMutation(mutationOptions, queryClient);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* @summary 취급상품 이름 일괄 매핑(엑셀 업로드)
|
||||||
|
*/
|
||||||
|
export const bulkMapSupplierItems = (
|
||||||
|
supplierId: string,
|
||||||
|
reqBulkMapByNames: ReqBulkMapByNames,
|
||||||
|
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
|
||||||
|
) => {
|
||||||
|
|
||||||
|
|
||||||
|
return customFetch<ResBulkMapByNames>(
|
||||||
|
{url: `/v1/supplier-item/by-supplier/${supplierId}/bulk`, method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json', },
|
||||||
|
data: reqBulkMapByNames, signal
|
||||||
|
},
|
||||||
|
options);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getBulkMapSupplierItemsMutationOptions = <TError = void | HTTPValidationError,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof bulkMapSupplierItems>>, TError,{supplierId: string;data: ReqBulkMapByNames}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
): UseMutationOptions<Awaited<ReturnType<typeof bulkMapSupplierItems>>, TError,{supplierId: string;data: ReqBulkMapByNames}, TContext> => {
|
||||||
|
|
||||||
|
const mutationKey = ['bulkMapSupplierItems'];
|
||||||
|
const {mutation: mutationOptions, request: requestOptions} = options ?
|
||||||
|
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
|
||||||
|
options
|
||||||
|
: {...options, mutation: {...options.mutation, mutationKey}}
|
||||||
|
: {mutation: { mutationKey, }, request: undefined};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const mutationFn: MutationFunction<Awaited<ReturnType<typeof bulkMapSupplierItems>>, {supplierId: string;data: ReqBulkMapByNames}> = (props) => {
|
||||||
|
const {supplierId,data} = props ?? {};
|
||||||
|
|
||||||
|
return bulkMapSupplierItems(supplierId,data,requestOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { mutationFn, ...mutationOptions }}
|
||||||
|
|
||||||
|
export type BulkMapSupplierItemsMutationResult = NonNullable<Awaited<ReturnType<typeof bulkMapSupplierItems>>>
|
||||||
|
export type BulkMapSupplierItemsMutationBody = ReqBulkMapByNames
|
||||||
|
export type BulkMapSupplierItemsMutationError = void | HTTPValidationError
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary 취급상품 이름 일괄 매핑(엑셀 업로드)
|
||||||
|
*/
|
||||||
|
export const useBulkMapSupplierItems = <TError = void | HTTPValidationError,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof bulkMapSupplierItems>>, TError,{supplierId: string;data: ReqBulkMapByNames}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
, queryClient?: QueryClient): UseMutationResult<
|
||||||
|
Awaited<ReturnType<typeof bulkMapSupplierItems>>,
|
||||||
|
TError,
|
||||||
|
{supplierId: string;data: ReqBulkMapByNames},
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
|
||||||
|
const mutationOptions = getBulkMapSupplierItemsMutationOptions(options);
|
||||||
|
|
||||||
|
return useMutation(mutationOptions, queryClient);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* @summary 취급상품 공급유형 수정
|
||||||
|
*/
|
||||||
|
export const updateSupplyType = (
|
||||||
|
supplierItemId: string,
|
||||||
|
reqUpdateSupplyType: ReqUpdateSupplyType,
|
||||||
|
options?: SecondParameter<typeof customFetch>,) => {
|
||||||
|
|
||||||
|
|
||||||
|
return customFetch<ResWebPacketProtocol>(
|
||||||
|
{url: `/v1/supplier-item/update/${supplierItemId}`, method: 'PATCH',
|
||||||
|
headers: {'Content-Type': 'application/json', },
|
||||||
|
data: reqUpdateSupplyType
|
||||||
|
},
|
||||||
|
options);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getUpdateSupplyTypeMutationOptions = <TError = void | HTTPValidationError,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateSupplyType>>, TError,{supplierItemId: string;data: ReqUpdateSupplyType}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
): UseMutationOptions<Awaited<ReturnType<typeof updateSupplyType>>, TError,{supplierItemId: string;data: ReqUpdateSupplyType}, TContext> => {
|
||||||
|
|
||||||
|
const mutationKey = ['updateSupplyType'];
|
||||||
|
const {mutation: mutationOptions, request: requestOptions} = options ?
|
||||||
|
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
|
||||||
|
options
|
||||||
|
: {...options, mutation: {...options.mutation, mutationKey}}
|
||||||
|
: {mutation: { mutationKey, }, request: undefined};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const mutationFn: MutationFunction<Awaited<ReturnType<typeof updateSupplyType>>, {supplierItemId: string;data: ReqUpdateSupplyType}> = (props) => {
|
||||||
|
const {supplierItemId,data} = props ?? {};
|
||||||
|
|
||||||
|
return updateSupplyType(supplierItemId,data,requestOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { mutationFn, ...mutationOptions }}
|
||||||
|
|
||||||
|
export type UpdateSupplyTypeMutationResult = NonNullable<Awaited<ReturnType<typeof updateSupplyType>>>
|
||||||
|
export type UpdateSupplyTypeMutationBody = ReqUpdateSupplyType
|
||||||
|
export type UpdateSupplyTypeMutationError = void | HTTPValidationError
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary 취급상품 공급유형 수정
|
||||||
|
*/
|
||||||
|
export const useUpdateSupplyType = <TError = void | HTTPValidationError,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateSupplyType>>, TError,{supplierItemId: string;data: ReqUpdateSupplyType}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
, queryClient?: QueryClient): UseMutationResult<
|
||||||
|
Awaited<ReturnType<typeof updateSupplyType>>,
|
||||||
|
TError,
|
||||||
|
{supplierItemId: string;data: ReqUpdateSupplyType},
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
|
||||||
|
const mutationOptions = getUpdateSupplyTypeMutationOptions(options);
|
||||||
|
|
||||||
|
return useMutation(mutationOptions, queryClient);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* @summary 취급상품 매핑 삭제
|
||||||
|
*/
|
||||||
|
export const deleteSupplierItem = (
|
||||||
|
supplierItemId: string,
|
||||||
|
options?: SecondParameter<typeof customFetch>,) => {
|
||||||
|
|
||||||
|
|
||||||
|
return customFetch<ResWebPacketProtocol>(
|
||||||
|
{url: `/v1/supplier-item/delete/${supplierItemId}`, method: 'DELETE'
|
||||||
|
},
|
||||||
|
options);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getDeleteSupplierItemMutationOptions = <TError = void | HTTPValidationError,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof deleteSupplierItem>>, TError,{supplierItemId: string}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
): UseMutationOptions<Awaited<ReturnType<typeof deleteSupplierItem>>, TError,{supplierItemId: string}, TContext> => {
|
||||||
|
|
||||||
|
const mutationKey = ['deleteSupplierItem'];
|
||||||
|
const {mutation: mutationOptions, request: requestOptions} = options ?
|
||||||
|
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
|
||||||
|
options
|
||||||
|
: {...options, mutation: {...options.mutation, mutationKey}}
|
||||||
|
: {mutation: { mutationKey, }, request: undefined};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const mutationFn: MutationFunction<Awaited<ReturnType<typeof deleteSupplierItem>>, {supplierItemId: string}> = (props) => {
|
||||||
|
const {supplierItemId} = props ?? {};
|
||||||
|
|
||||||
|
return deleteSupplierItem(supplierItemId,requestOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { mutationFn, ...mutationOptions }}
|
||||||
|
|
||||||
|
export type DeleteSupplierItemMutationResult = NonNullable<Awaited<ReturnType<typeof deleteSupplierItem>>>
|
||||||
|
|
||||||
|
export type DeleteSupplierItemMutationError = void | HTTPValidationError
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary 취급상품 매핑 삭제
|
||||||
|
*/
|
||||||
|
export const useDeleteSupplierItem = <TError = void | HTTPValidationError,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof deleteSupplierItem>>, TError,{supplierItemId: string}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
, queryClient?: QueryClient): UseMutationResult<
|
||||||
|
Awaited<ReturnType<typeof deleteSupplierItem>>,
|
||||||
|
TError,
|
||||||
|
{supplierItemId: string},
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
|
||||||
|
const mutationOptions = getDeleteSupplierItemMutationOptions(options);
|
||||||
|
|
||||||
|
return useMutation(mutationOptions, queryClient);
|
||||||
|
}
|
||||||
|
|
||||||
178
negodata/front/src/components/ui/combobox.tsx
Normal file
178
negodata/front/src/components/ui/combobox.tsx
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||||
|
import { Search, Check, Loader2, ChevronsUpDown } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Input } from './input';
|
||||||
|
import { Typography } from './typography';
|
||||||
|
|
||||||
|
// 서버검색 콤보박스 — 목록(options)은 부모가 쿼리에 맞춰 조회해 넘기고, 검색어 디바운스는 이 컴포넌트가 처리한다.
|
||||||
|
// variant: 'field'(트리거+팝오버, 단일선택 폼용) / 'inline'(검색창+리스트 상시노출, 다중 체크리스트용).
|
||||||
|
export type ComboOption = {
|
||||||
|
id: string;
|
||||||
|
label: string; // 검색결과에 없을 때 선택 표시용 텍스트
|
||||||
|
node?: ReactNode; // 커스텀 행(미지정 시 label 렌더)
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ComboboxProps = {
|
||||||
|
options: ComboOption[];
|
||||||
|
onQueryChange: (q: string) => void; // 내부 디바운스 후 호출
|
||||||
|
loading?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
emptyText?: string;
|
||||||
|
debounceMs?: number;
|
||||||
|
id?: string;
|
||||||
|
className?: string;
|
||||||
|
maxListHeight?: string; // tailwind, default max-h-56
|
||||||
|
variant?: 'field' | 'inline';
|
||||||
|
multiple?: boolean;
|
||||||
|
// single
|
||||||
|
value?: string;
|
||||||
|
selectedLabel?: ReactNode;
|
||||||
|
onSelect?: (opt: ComboOption) => void;
|
||||||
|
// multi
|
||||||
|
values?: string[];
|
||||||
|
onToggle?: (opt: ComboOption) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Combobox({
|
||||||
|
options,
|
||||||
|
onQueryChange,
|
||||||
|
loading,
|
||||||
|
placeholder = '선택...',
|
||||||
|
searchPlaceholder = '검색...',
|
||||||
|
emptyText = '결과가 없습니다',
|
||||||
|
debounceMs = 300,
|
||||||
|
id,
|
||||||
|
className,
|
||||||
|
maxListHeight = 'max-h-56',
|
||||||
|
variant = 'field',
|
||||||
|
multiple = false,
|
||||||
|
value,
|
||||||
|
selectedLabel,
|
||||||
|
onSelect,
|
||||||
|
values = [],
|
||||||
|
onToggle,
|
||||||
|
}: ComboboxProps) {
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 검색어 디바운스 → onQueryChange. 콜백은 ref로 잡아 text 변화에만 반응.
|
||||||
|
const qcRef = useRef(onQueryChange);
|
||||||
|
qcRef.current = onQueryChange;
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => qcRef.current(text.trim()), debounceMs);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [text, debounceMs]);
|
||||||
|
|
||||||
|
// field 팝오버 바깥 클릭 시 닫기.
|
||||||
|
useEffect(() => {
|
||||||
|
if (variant === 'inline') return;
|
||||||
|
const onDoc = (e: MouseEvent) => {
|
||||||
|
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', onDoc);
|
||||||
|
return () => document.removeEventListener('mousedown', onDoc);
|
||||||
|
}, [variant]);
|
||||||
|
|
||||||
|
const isSelected = (oid: string) => (multiple ? values.includes(oid) : value === oid);
|
||||||
|
|
||||||
|
const handlePick = (opt: ComboOption) => {
|
||||||
|
if (opt.disabled) return;
|
||||||
|
if (multiple) onToggle?.(opt);
|
||||||
|
else {
|
||||||
|
onSelect?.(opt);
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchInput = (
|
||||||
|
<div className="relative">
|
||||||
|
<Search size={13} className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
type="text"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
className="pl-7 text-xs"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
{loading && <Loader2 size={13} className="absolute right-2 top-1/2 -translate-y-1/2 animate-spin text-muted-foreground" />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const list = (
|
||||||
|
<div className={cn('border border-border rounded bg-background overflow-y-auto', maxListHeight)}>
|
||||||
|
{loading && options.length === 0 ? (
|
||||||
|
<div className="flex items-center gap-2 p-3 text-muted-foreground text-[11px]">
|
||||||
|
<Loader2 size={13} className="animate-spin" />
|
||||||
|
<Typography as="span" variant="small" className="text-[11px]">불러오는 중…</Typography>
|
||||||
|
</div>
|
||||||
|
) : options.length === 0 ? (
|
||||||
|
<Typography as="p" variant="small" className="p-3 text-muted-foreground text-[11px]">{emptyText}</Typography>
|
||||||
|
) : (
|
||||||
|
options.map((opt) => {
|
||||||
|
const sel = isSelected(opt.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.id}
|
||||||
|
type="button"
|
||||||
|
disabled={opt.disabled}
|
||||||
|
onClick={() => handlePick(opt)}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center justify-between gap-2 p-2 text-left border-b border-border last:border-b-0 hover:bg-muted/40 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer transition-colors',
|
||||||
|
sel && 'bg-primary/5',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
{opt.node ?? <Typography as="span" variant="small">{opt.label}</Typography>}
|
||||||
|
</span>
|
||||||
|
{multiple ? (
|
||||||
|
<input type="checkbox" checked={sel} readOnly className="accent-primary h-3.5 w-3.5 shrink-0" />
|
||||||
|
) : (
|
||||||
|
sel && <Check size={14} className="text-primary shrink-0" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (variant === 'inline') {
|
||||||
|
return (
|
||||||
|
<div ref={rootRef} className={cn('space-y-1.5', className)}>
|
||||||
|
{searchInput}
|
||||||
|
{list}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// field: 트리거(선택 요약) + 팝오버(검색창 + 리스트)
|
||||||
|
const hasSelection = multiple ? values.length > 0 : !!value;
|
||||||
|
const summary = multiple
|
||||||
|
? (values.length ? `${values.length}개 선택됨` : placeholder)
|
||||||
|
: (value ? selectedLabel ?? '선택됨' : placeholder);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={rootRef} className={cn('relative', className)}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id={id}
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
className="w-full flex items-center justify-between gap-2 p-2 bg-background border border-border rounded text-xs text-left hover:bg-muted/20 cursor-pointer"
|
||||||
|
>
|
||||||
|
<span className={cn('truncate', !hasSelection && 'text-muted-foreground')}>{summary}</span>
|
||||||
|
<ChevronsUpDown size={14} className="text-muted-foreground shrink-0" />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute z-50 mt-1 w-full rounded border border-border bg-card shadow-lg p-1.5 space-y-1.5">
|
||||||
|
{searchInput}
|
||||||
|
{list}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -18,17 +18,26 @@ type RawRow = {
|
|||||||
managerName: string;
|
managerName: string;
|
||||||
managerEmail: string;
|
managerEmail: string;
|
||||||
totalRevenue: string;
|
totalRevenue: string;
|
||||||
|
products: string; // 취급상품 — 상품명 콤마(,) 나열. 업로드 시 매핑테이블로 들어간다.
|
||||||
};
|
};
|
||||||
|
|
||||||
type ValidatedRow = RawRow & { status: '정상' | '오류'; message: string };
|
type ValidatedRow = RawRow & { status: '정상' | '오류'; message: string };
|
||||||
|
|
||||||
// 업로드 양식 한 줄(예시 행)
|
// 업로드 양식 한 줄(예시 행)
|
||||||
type TemplateRow = { name: string; code: string; managerName: string; managerEmail: string; totalRevenue: string };
|
type TemplateRow = { name: string; code: string; managerName: string; managerEmail: string; totalRevenue: string; products: string };
|
||||||
|
|
||||||
|
// 협력사 1건 + 그 협력사에 매핑할 취급상품명 리스트.
|
||||||
|
export type PartnerUploadRow = { supplier: SupplierCreate; products: string[] };
|
||||||
|
// 일괄 등록 결과 — 협력사 등록 실패(행별) + 이름 미매칭으로 건너뛴 취급상품명들.
|
||||||
|
export type PartnerBulkResult = { failures: BulkFailure[]; unmatchedProducts: string[] };
|
||||||
|
|
||||||
|
// "상품A, 상품B" → ['상품A','상품B'] (공백/빈값 제거).
|
||||||
|
const splitNames = (s: string): string[] => s.split(',').map((t) => t.trim()).filter(Boolean);
|
||||||
|
|
||||||
type ExcelUploadModalProps = {
|
type ExcelUploadModalProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
partners: Partner[]; // 코드 중복 검사용
|
partners: Partner[]; // 코드 중복 검사용
|
||||||
onConfirm: (rows: SupplierCreate[]) => Promise<BulkFailure[]>;
|
onConfirm: (rows: PartnerUploadRow[]) => Promise<PartnerBulkResult>;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -82,8 +91,9 @@ export function downloadPartnerTemplate() {
|
|||||||
{ header: '담당자명', value: (r) => r.managerName },
|
{ header: '담당자명', value: (r) => r.managerName },
|
||||||
{ header: '담당자이메일', value: (r) => r.managerEmail },
|
{ header: '담당자이메일', value: (r) => r.managerEmail },
|
||||||
{ header: '총매출액', value: (r) => r.totalRevenue },
|
{ header: '총매출액', value: (r) => r.totalRevenue },
|
||||||
|
{ header: '취급상품', value: (r) => r.products },
|
||||||
],
|
],
|
||||||
[{ name: '예시) (주)한빛정밀', code: 'PART-EXAMPLE-001', managerName: '김철수 과장', managerEmail: 'cs.kim@example.com', totalRevenue: '5000000000' }],
|
[{ name: '예시) (주)한빛정밀', code: 'PART-EXAMPLE-001', managerName: '김철수 과장', managerEmail: 'cs.kim@example.com', totalRevenue: '5000000000', products: '고압 에어 컴프레서, 스테인리스 볼밸브' }],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -124,6 +134,7 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp
|
|||||||
managerName: r['담당자명'] ?? '',
|
managerName: r['담당자명'] ?? '',
|
||||||
managerEmail: r['담당자이메일'] ?? '',
|
managerEmail: r['담당자이메일'] ?? '',
|
||||||
totalRevenue: r['총매출액'] ?? '',
|
totalRevenue: r['총매출액'] ?? '',
|
||||||
|
products: r['취급상품'] ?? '',
|
||||||
}));
|
}));
|
||||||
setExcelFile(file.name);
|
setExcelFile(file.name);
|
||||||
setRows(loaded);
|
setRows(loaded);
|
||||||
@ -147,7 +158,7 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 인라인 편집 — 원본 필드만 갱신(재검증은 파생이 처리)
|
// 인라인 편집 — 원본 필드만 갱신(재검증은 파생이 처리)
|
||||||
const handleUpdateField = (id: string, field: 'name' | 'code' | 'managerName' | 'managerEmail', value: string) => {
|
const handleUpdateField = (id: string, field: 'name' | 'code' | 'managerName' | 'managerEmail' | 'products', value: string) => {
|
||||||
setRows((cur) => cur.map((row) => (row.id === id ? { ...row, [field]: value } : row)));
|
setRows((cur) => cur.map((row) => (row.id === id ? { ...row, [field]: value } : row)));
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -162,10 +173,16 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const failures = await onConfirm(validRows.map(toSupplierCreate));
|
const { failures, unmatchedProducts } = await onConfirm(
|
||||||
|
validRows.map((r) => ({ supplier: toSupplierCreate(r), products: splitNames(r.products) })),
|
||||||
|
);
|
||||||
const okCount = validRows.length - failures.length;
|
const okCount = validRows.length - failures.length;
|
||||||
|
// 이름이 회사 상품목록에 없어 매핑 못한 취급상품 — 스킵하고 결과에 부기(결정: 미매칭은 스킵+리포트).
|
||||||
|
const unmatchedNote = unmatchedProducts.length
|
||||||
|
? ` · 미매칭 취급상품 ${unmatchedProducts.length}건 건너뜀(${unmatchedProducts.slice(0, 5).join(', ')}${unmatchedProducts.length > 5 ? '…' : ''})`
|
||||||
|
: '';
|
||||||
if (failures.length === 0) {
|
if (failures.length === 0) {
|
||||||
showToast(`총 ${okCount}개 협력사가 서버에 일괄 등록되었습니다.`, 'success');
|
showToast(`총 ${okCount}개 협력사가 서버에 일괄 등록되었습니다.${unmatchedNote}`, unmatchedProducts.length ? 'info' : 'success');
|
||||||
close();
|
close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -175,7 +192,7 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp
|
|||||||
const okCodes = new Set(validRows.map((r) => r.code).filter((c) => failMap[c] === undefined));
|
const okCodes = new Set(validRows.map((r) => r.code).filter((c) => failMap[c] === undefined));
|
||||||
setServerErrors(failMap);
|
setServerErrors(failMap);
|
||||||
setRows((cur) => cur.filter((r) => !okCodes.has(r.code)));
|
setRows((cur) => cur.filter((r) => !okCodes.has(r.code)));
|
||||||
showToast(`${okCount}건 등록 완료 · ${failures.length}건 서버 검증 실패(중복코드 등)`, 'error');
|
showToast(`${okCount}건 등록 완료 · ${failures.length}건 서버 검증 실패(중복코드 등)${unmatchedNote}`, 'error');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err instanceof Error ? err.message : '엑셀 일괄 등록 실패', 'error');
|
showToast(err instanceof Error ? err.message : '엑셀 일괄 등록 실패', 'error');
|
||||||
}
|
}
|
||||||
@ -271,6 +288,7 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp
|
|||||||
<TableHead className="p-2 font-semibold">협력사코드 *</TableHead>
|
<TableHead className="p-2 font-semibold">협력사코드 *</TableHead>
|
||||||
<TableHead className="p-2 font-semibold">담당자명 *</TableHead>
|
<TableHead className="p-2 font-semibold">담당자명 *</TableHead>
|
||||||
<TableHead className="p-2 font-semibold">담당자 이메일 *</TableHead>
|
<TableHead className="p-2 font-semibold">담당자 이메일 *</TableHead>
|
||||||
|
<TableHead className="p-2 font-semibold">취급상품 (,로 구분)</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody className="divide-y divide-border">
|
<TableBody className="divide-y divide-border">
|
||||||
@ -331,6 +349,15 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp
|
|||||||
onChange={(e) => handleUpdateField(row.id, 'managerEmail', e.target.value)}
|
onChange={(e) => handleUpdateField(row.id, 'managerEmail', e.target.value)}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="p-2">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
className="bg-muted/20 hover:bg-muted/50 text-foreground"
|
||||||
|
value={row.products}
|
||||||
|
onChange={(e) => handleUpdateField(row.id, 'products', e.target.value)}
|
||||||
|
placeholder="상품명, 상품명"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { Typography } from '@/components/ui/typography';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Sheet } from '@/components/ui/sheet';
|
import { Sheet } from '@/components/ui/sheet';
|
||||||
|
import { SupplierItemsManager } from './SupplierItemsManager';
|
||||||
import { type Partner } from '../types';
|
import { type Partner } from '../types';
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
@ -189,6 +190,9 @@ export function PartnerFormSheet({
|
|||||||
{errors.managerPhone && <p className="text-[10px] text-rose-500">{errors.managerPhone.message}</p>}
|
{errors.managerPhone && <p className="text-[10px] text-rose-500">{errors.managerPhone.message}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 취급상품 관리 — 수정 모드(협력사 확정)에서만. 추가/삭제/유형변경은 즉시 서버 반영. */}
|
||||||
|
{mode === 'edit' && partner && <SupplierItemsManager supplierId={partner.supplier_id} />}
|
||||||
|
|
||||||
{/* Buttons wrapper */}
|
{/* Buttons wrapper */}
|
||||||
<div className="pt-4 flex items-center gap-2 border-t border-border mt-8 justify-between">
|
<div className="pt-4 flex items-center gap-2 border-t border-border mt-8 justify-between">
|
||||||
{mode === 'edit' && partner && (
|
{mode === 'edit' && partner && (
|
||||||
|
|||||||
@ -0,0 +1,147 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Plus, Trash2 } from 'lucide-react';
|
||||||
|
import { useListItems } from '@/api/generated/item/item';
|
||||||
|
import { SupplierType } from '@/api/generated/model';
|
||||||
|
import { Typography } from '@/components/ui/typography';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Combobox, type ComboOption } from '@/components/ui/combobox';
|
||||||
|
import { SUPPLIER_TYPE_OPTIONS, supplierTypeLabel } from '@/lib/enumLabels';
|
||||||
|
import { showToast } from '@/lib/notify';
|
||||||
|
import { useSupplierItems } from '../hooks/useSupplierItems';
|
||||||
|
|
||||||
|
// 협력사 상세의 취급상품 관리 섹션 — 상품 추가/삭제 + 공급유형(제조/유통/총판/없음) 수정.
|
||||||
|
// 각 조작은 즉시 서버 반영(협력사 기본정보 저장과 독립).
|
||||||
|
export function SupplierItemsManager({ supplierId }: { supplierId: string }) {
|
||||||
|
const { items, isLoading, addItem, changeType, removeItem } = useSupplierItems(supplierId);
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const catalogQuery = useListItems({ search: q || undefined, size: 30 }); // 서버검색(상품 100개 이상도 검색으로 도달)
|
||||||
|
|
||||||
|
const [pickItemId, setPickItemId] = useState('');
|
||||||
|
const [pickLabel, setPickLabel] = useState('');
|
||||||
|
const [pickType, setPickType] = useState(String(SupplierType.NONE)); // 기본 없음(0)
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const mappedIds = new Set(items.map((m) => m.item_id));
|
||||||
|
const options: ComboOption[] = (catalogQuery.data?.items ?? [])
|
||||||
|
.filter((it) => !mappedIds.has(it.item_id))
|
||||||
|
.map((it) => ({ id: it.item_id, label: `${it.name}${it.code ? ` [${it.code}]` : ''}` }));
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
if (!pickItemId) {
|
||||||
|
showToast('추가할 상품을 선택하세요.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await addItem(pickItemId, Number(pickType));
|
||||||
|
setPickItemId('');
|
||||||
|
setPickLabel('');
|
||||||
|
setQ('');
|
||||||
|
showToast('취급상품이 추가되었습니다.', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err instanceof Error ? err.message : '취급상품 추가 실패', 'error');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangeType = async (supplierItemId: string, v: string) => {
|
||||||
|
try {
|
||||||
|
await changeType(supplierItemId, Number(v));
|
||||||
|
} catch {
|
||||||
|
showToast('공급유형 변경에 실패했습니다.', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = async (supplierItemId: string) => {
|
||||||
|
try {
|
||||||
|
await removeItem(supplierItemId);
|
||||||
|
showToast('취급상품이 삭제되었습니다.', 'info');
|
||||||
|
} catch {
|
||||||
|
showToast('취급상품 삭제에 실패했습니다.', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pt-4 border-t border-border space-y-2">
|
||||||
|
<Typography as="label" variant="label">취급상품 ({items.length})</Typography>
|
||||||
|
|
||||||
|
{/* 추가 행 — 상품 + 공급유형 선택 후 추가 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<Combobox
|
||||||
|
id="supplier-item-pick"
|
||||||
|
options={options}
|
||||||
|
loading={catalogQuery.isLoading}
|
||||||
|
onQueryChange={setQ}
|
||||||
|
value={pickItemId || undefined}
|
||||||
|
selectedLabel={pickLabel}
|
||||||
|
onSelect={(opt) => { setPickItemId(opt.id); setPickLabel(opt.label); }}
|
||||||
|
placeholder="취급상품으로 추가할 상품 검색..."
|
||||||
|
searchPlaceholder="상품명·코드로 검색..."
|
||||||
|
emptyText="일치하는 상품이 없습니다"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-24 shrink-0">
|
||||||
|
<Select value={pickType} onValueChange={(v) => setPickType(v ?? String(SupplierType.NONE))}>
|
||||||
|
<SelectTrigger id="supplier-item-pick-type" className="w-full">
|
||||||
|
<SelectValue>{(value) => supplierTypeLabel(Number(value))}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SUPPLIER_TYPE_OPTIONS.map((o) => (
|
||||||
|
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button type="button" size="sm" onClick={handleAdd} disabled={busy || !pickItemId}>
|
||||||
|
<Plus />
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 현재 취급상품 목록 */}
|
||||||
|
<div className="border border-border rounded divide-y divide-border max-h-48 overflow-y-auto">
|
||||||
|
{isLoading ? (
|
||||||
|
<Typography as="p" variant="small" className="p-3 text-muted-foreground text-[11px]">불러오는 중…</Typography>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<Typography as="p" variant="small" className="p-3 text-muted-foreground text-[11px]">등록된 취급상품이 없습니다.</Typography>
|
||||||
|
) : (
|
||||||
|
items.map((m) => (
|
||||||
|
<div key={m.supplier_item_id} className="flex items-center justify-between gap-2 p-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<Typography as="span" variant="small" className="font-semibold block truncate">{m.item_name}</Typography>
|
||||||
|
{m.item_code && (
|
||||||
|
<Typography as="span" variant="small" className="text-muted-foreground text-[10px]">{m.item_code}</Typography>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
<div className="w-24">
|
||||||
|
<Select value={String(m.supply_type)} onValueChange={(v) => handleChangeType(m.supplier_item_id, v ?? String(SupplierType.NONE))}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue>{(value) => supplierTypeLabel(Number(value))}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SUPPLIER_TYPE_OPTIONS.map((o) => (
|
||||||
|
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemove(m.supplier_item_id)}
|
||||||
|
title="취급상품 삭제"
|
||||||
|
className="p-1 rounded text-muted-foreground hover:text-rose-600 hover:bg-rose-500/10 cursor-pointer"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -5,12 +5,14 @@ import {
|
|||||||
updateSupplier,
|
updateSupplier,
|
||||||
deleteSupplier,
|
deleteSupplier,
|
||||||
} from '@/api/generated/supplier/supplier';
|
} from '@/api/generated/supplier/supplier';
|
||||||
|
import { bulkMapSupplierItems } from '@/api/generated/supplier-item/supplier-item';
|
||||||
import type { ListSuppliersParams } from '@/api/generated/model/listSuppliersParams';
|
import type { ListSuppliersParams } from '@/api/generated/model/listSuppliersParams';
|
||||||
import type { ReqCreateSupplier } from '@/api/generated/model/reqCreateSupplier';
|
import type { ReqCreateSupplier } from '@/api/generated/model/reqCreateSupplier';
|
||||||
import type { ReqUpdateSupplier } from '@/api/generated/model/reqUpdateSupplier';
|
import type { ReqUpdateSupplier } from '@/api/generated/model/reqUpdateSupplier';
|
||||||
import type { ResSupplier } from '@/api/generated/model/resSupplier';
|
import type { ResSupplier } from '@/api/generated/model/resSupplier';
|
||||||
import type { SupplierData } from '@/api/generated/model/supplierData';
|
import type { SupplierData } from '@/api/generated/model/supplierData';
|
||||||
import type { BulkFailure } from '@/lib/excel';
|
import type { BulkFailure } from '@/lib/excel';
|
||||||
|
import type { PartnerUploadRow, PartnerBulkResult } from '../components/ExcelUploadModal';
|
||||||
import type { Partner } from '../types';
|
import type { Partner } from '../types';
|
||||||
|
|
||||||
// 엑셀 중복검사 모달이 참조하는 "전체 협력사"용 메타 쿼리(최대 100건).
|
// 엑셀 중복검사 모달이 참조하는 "전체 협력사"용 메타 쿼리(최대 100건).
|
||||||
@ -54,18 +56,33 @@ export function usePartners(params: ListSuppliersParams) {
|
|||||||
};
|
};
|
||||||
// 엑셀 일괄 등록 — 행별로 순차 생성하되 실패해도 멈추지 않고 사유를 모은다.
|
// 엑셀 일괄 등록 — 행별로 순차 생성하되 실패해도 멈추지 않고 사유를 모은다.
|
||||||
// 서버 DB 검증(중복코드 등)에 걸린 행은 BulkFailure 로 반환 → 모달이 해당 행만 사유와 함께 남긴다.
|
// 서버 DB 검증(중복코드 등)에 걸린 행은 BulkFailure 로 반환 → 모달이 해당 행만 사유와 함께 남긴다.
|
||||||
const bulkCreate = async (rows: ReqCreateSupplier[]): Promise<BulkFailure[]> => {
|
// 등록 성공 시 취급상품(상품명 리스트)을 매핑테이블로 밀어넣는다. 이름 미매칭분은 서버가 스킵하고 돌려줘 리포트한다.
|
||||||
|
const bulkCreate = async (rows: PartnerUploadRow[]): Promise<PartnerBulkResult> => {
|
||||||
const failures: BulkFailure[] = [];
|
const failures: BulkFailure[] = [];
|
||||||
for (const row of rows) {
|
const unmatched = new Set<string>();
|
||||||
|
for (const { supplier, products } of rows) {
|
||||||
try {
|
try {
|
||||||
const msg = supplierError(await createSupplier(row));
|
const res = await createSupplier(supplier);
|
||||||
if (msg) failures.push({ code: row.code ?? '', message: msg });
|
const msg = supplierError(res);
|
||||||
|
if (msg) {
|
||||||
|
failures.push({ code: supplier.code ?? '', message: msg });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const newId = res.supplier?.supplier_id;
|
||||||
|
if (newId && products.length) {
|
||||||
|
try {
|
||||||
|
const mapRes = await bulkMapSupplierItems(newId, { names: products });
|
||||||
|
(mapRes.unmatched ?? []).forEach((n) => unmatched.add(n));
|
||||||
|
} catch {
|
||||||
|
// 취급상품 매핑 실패는 협력사 등록 자체를 되돌리지 않는다(등록은 성공 처리).
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
failures.push({ code: row.code ?? '', message: err instanceof Error ? err.message : '등록 실패' });
|
failures.push({ code: supplier.code ?? '', message: err instanceof Error ? err.message : '등록 실패' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await refresh();
|
await refresh();
|
||||||
return failures;
|
return { failures, unmatchedProducts: [...unmatched] };
|
||||||
};
|
};
|
||||||
|
|
||||||
// 테이블(현재 페이지) 협력사 + 서버 전체 건수.
|
// 테이블(현재 페이지) 협력사 + 서버 전체 건수.
|
||||||
|
|||||||
@ -0,0 +1,45 @@
|
|||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
useListSupplierItems,
|
||||||
|
createSupplierItem,
|
||||||
|
updateSupplyType,
|
||||||
|
deleteSupplierItem,
|
||||||
|
getListSupplierItemsQueryKey,
|
||||||
|
} from '@/api/generated/supplier-item/supplier-item';
|
||||||
|
import type { SupplierItemData } from '@/api/generated/model/supplierItemData';
|
||||||
|
|
||||||
|
// 협력사 취급상품(매핑) 서버 데이터 + CRUD. 협력사 상세(PartnerFormSheet)에서 쓴다.
|
||||||
|
// supplierId 가 없으면(신규 등록 폼) 쿼리는 비활성.
|
||||||
|
export function useSupplierItems(supplierId: string | undefined) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const listQuery = useListSupplierItems(supplierId ?? '', { query: { enabled: !!supplierId } });
|
||||||
|
|
||||||
|
const refresh = () =>
|
||||||
|
supplierId
|
||||||
|
? queryClient.invalidateQueries({ queryKey: getListSupplierItemsQueryKey(supplierId) })
|
||||||
|
: Promise.resolve();
|
||||||
|
|
||||||
|
const items: SupplierItemData[] = listQuery.data?.supplier_items ?? [];
|
||||||
|
|
||||||
|
const addItem = async (itemId: string, supplyType: number) => {
|
||||||
|
if (!supplierId) return;
|
||||||
|
const res = await createSupplierItem({ supplier_id: supplierId, item_id: itemId, supply_type: supplyType });
|
||||||
|
const r = res.result;
|
||||||
|
if (r && r.success === false) {
|
||||||
|
throw new Error(r.desc === 'DB_ALREADY_SAME_KEY' ? '이미 등록된 취급상품입니다.' : r.desc || '취급상품 추가 실패');
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeType = async (supplierItemId: string, supplyType: number) => {
|
||||||
|
await updateSupplyType(supplierItemId, { supply_type: supplyType });
|
||||||
|
await refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeItem = async (supplierItemId: string) => {
|
||||||
|
await deleteSupplierItem(supplierItemId);
|
||||||
|
await refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
return { items, isLoading: listQuery.isLoading, addItem, changeType, removeItem };
|
||||||
|
}
|
||||||
@ -1,12 +1,18 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { X, PlusSquare, ArrowRight, Loader2, Gavel } from 'lucide-react';
|
import { X, PlusSquare, ArrowRight, Loader2, Gavel } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { useGetSupplierLastType } from '@/api/generated/quotation/quotation';
|
import { useGetSupplierLastType } from '@/api/generated/quotation/quotation';
|
||||||
|
import { useListItemSupplyTypes } from '@/api/generated/supplier-item/supplier-item';
|
||||||
|
import { useListItems, useGetItem } from '@/api/generated/item/item';
|
||||||
|
import { useListSuppliers } from '@/api/generated/supplier/supplier';
|
||||||
|
import { useListCards } from '@/api/generated/card/card';
|
||||||
|
import { mapCardData } from '@/features/cards/types';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Typography, typographyVariants } from '@/components/ui/typography';
|
import { Typography, typographyVariants } from '@/components/ui/typography';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Combobox, type ComboOption } from '@/components/ui/combobox';
|
||||||
import type { Product, Partner, QuotationSetting, NegotiationCard } from '../types';
|
import type { Product, Partner, QuotationSetting, NegotiationCard } from '../types';
|
||||||
import type { CreateQuotationInput } from '../hooks/useQuotations';
|
import type { CreateQuotationInput } from '../hooks/useQuotations';
|
||||||
import { QuotationType } from '@/api/generated/model';
|
import { QuotationType } from '@/api/generated/model';
|
||||||
@ -88,11 +94,71 @@ export function QuotationCreateModal({
|
|||||||
}, [renegoSupplierId, prevSupplierType]);
|
}, [renegoSupplierId, prevSupplierType]);
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// ── 픽리스트 서버검색(상품/협력사/카드) — size 캡 없이 검색으로 도달. 미검색이면 부모가 넘긴 목록으로 기본 노출.
|
||||||
|
const [productQ, setProductQ] = useState('');
|
||||||
|
const [productLabel, setProductLabel] = useState('');
|
||||||
|
const [supplierQ, setSupplierQ] = useState('');
|
||||||
|
const [cardQ, setCardQ] = useState('');
|
||||||
|
const productSearch = useListItems({ search: productQ || undefined, size: 30 });
|
||||||
|
const supplierSearch = useListSuppliers({ search: supplierQ || undefined, size: 30 });
|
||||||
|
const cardSearch = useListCards({ search: cardQ || undefined, size: 30 });
|
||||||
|
|
||||||
|
// 선택 상품은 검색으로 목록이 좁혀져도 파생값(목표가 후보)이 안 깨지게 id로 단건 조회한다.
|
||||||
|
const selItem = useGetItem(productId, { query: { enabled: !!productId } }).data?.item ?? null;
|
||||||
// 인터넷최저가·매입가·판매가는 상품 속성 — 모달에선 읽기전용으로만 보여주고, 수정은 상품 상세에서 한다.
|
// 인터넷최저가·매입가·판매가는 상품 속성 — 모달에선 읽기전용으로만 보여주고, 수정은 상품 상세에서 한다.
|
||||||
const selectedProduct = products.find((p) => p.id === productId);
|
const internetLowest = selItem?.internet_lowest_price ?? null;
|
||||||
const internetLowest = selectedProduct?.internet_lowest_price ?? null;
|
const purchase = selItem?.purchase_price ?? null;
|
||||||
const purchase = selectedProduct?.purchase_price ?? null;
|
const selling = selItem?.selling_price ?? null;
|
||||||
const selling = selectedProduct?.selling_price ?? null;
|
|
||||||
|
// 선택 상품의 협력사별 공급유형(제조/유통/총판/없음) — 협력사 리스트에 배지로 덧붙인다(리스트 자체는 재조회 안 함).
|
||||||
|
const supplyTypeQuery = useListItemSupplyTypes(productId, { query: { enabled: !!productId } });
|
||||||
|
const supplyTypeBySupplier = useMemo(() => {
|
||||||
|
const m = new Map<string, number>();
|
||||||
|
(supplyTypeQuery.data?.suppliers ?? []).forEach((s) => m.set(s.supplier_id, s.supply_type));
|
||||||
|
return m;
|
||||||
|
}, [supplyTypeQuery.data]);
|
||||||
|
|
||||||
|
// 콤보박스 옵션 — 미검색이면 부모 목록, 검색 중이면 서버결과.
|
||||||
|
const productOptions: ComboOption[] = productQ
|
||||||
|
? (productSearch.data?.items ?? []).map((it) => ({ id: it.item_id, label: `${it.name}${it.code ? ` [${it.code}]` : ''}` }))
|
||||||
|
: products.map((p) => ({ id: p.id ?? '', label: `${p.name}${p.code ? ` [${p.code}]` : ''}` }));
|
||||||
|
|
||||||
|
const supplierRows = supplierQ
|
||||||
|
? (supplierSearch.data?.suppliers ?? []).map((sp) => ({ id: sp.supplier_id, name: sp.name, email: sp.manager_email ?? '' }))
|
||||||
|
: partners.map((p) => ({ id: p.id ?? '', name: p.name, email: p.managerEmail ?? '' }));
|
||||||
|
const supplierOptions: ComboOption[] = supplierRows.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
label: s.name,
|
||||||
|
node: (
|
||||||
|
<div className="flex items-center justify-between gap-2 w-full">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<Typography as="span" variant="small" className="font-semibold block truncate">{s.name}</Typography>
|
||||||
|
<Typography as="span" variant="small" className="text-muted-foreground">이메일: {s.email}</Typography>
|
||||||
|
</div>
|
||||||
|
{productId && <SupplyTypeBadge type={supplyTypeBySupplier.get(s.id)} />}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const cardRows = cardQ ? (cardSearch.data?.cards ?? []).map(mapCardData) : cards;
|
||||||
|
const cardOptions: ComboOption[] = cardRows
|
||||||
|
.filter((c) => !c.isWildcard || c.status === 'ACTIVE')
|
||||||
|
.map((card) => ({
|
||||||
|
id: card.id,
|
||||||
|
label: card.title,
|
||||||
|
node: (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Typography as="span" variant="small" className="text-muted-foreground font-mono block leading-none">{card.code}</Typography>
|
||||||
|
<span className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${card.isWildcard ? 'bg-amber-50 text-amber-700' : 'bg-zinc-100 text-zinc-600'}`}>
|
||||||
|
{card.isWildcard ? '와일드' : '협상'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Typography as="span" variant="small" className="mt-1 block leading-tight">{card.title}</Typography>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
// 상품에 산정 후보가 있는지(인터넷=공통, 매입·판매=재 한정). 없으면 MD가가 유일한 후보 → 필수가 된다.
|
// 상품에 산정 후보가 있는지(인터넷=공통, 매입·판매=재 한정). 없으면 MD가가 유일한 후보 → 필수가 된다.
|
||||||
const mdNum = Number(mdPrice) || 0;
|
const mdNum = Number(mdPrice) || 0;
|
||||||
const hasItemCandidate = internetLowest != null || (isReType && (purchase != null || selling != null));
|
const hasItemCandidate = internetLowest != null || (isReType && (purchase != null || selling != null));
|
||||||
@ -247,25 +313,18 @@ export function QuotationCreateModal({
|
|||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Typography as="label" variant="label">상품</Typography>
|
<Typography as="label" variant="label">상품</Typography>
|
||||||
<Select value={productId} onValueChange={(v) => setProductId(v ?? '')}>
|
<Combobox
|
||||||
<SelectTrigger id="wizard-product" className="w-full">
|
id="wizard-product"
|
||||||
<SelectValue>
|
options={productOptions}
|
||||||
{(value) => {
|
loading={productSearch.isLoading}
|
||||||
const p = products.find((pp) => pp.id === value);
|
onQueryChange={setProductQ}
|
||||||
return p
|
value={productId || undefined}
|
||||||
? `${p.name} [${p.code}] (기준가: ₩${(p.price ?? 0).toLocaleString()})`
|
selectedLabel={productLabel}
|
||||||
: '협상 대상 상품을 고르세요...';
|
onSelect={(opt) => { setProductId(opt.id); setProductLabel(opt.label); }}
|
||||||
}}
|
placeholder="협상 대상 상품을 고르세요..."
|
||||||
</SelectValue>
|
searchPlaceholder="상품명·코드로 검색..."
|
||||||
</SelectTrigger>
|
emptyText="일치하는 상품이 없습니다"
|
||||||
<SelectContent>
|
/>
|
||||||
{products.filter((p) => p.status === 'ACTIVE').map((p) => (
|
|
||||||
<SelectItem key={p.id} value={p.id}>
|
|
||||||
{p.name} [{p.code}] (기준가: ₩{(p.price ?? 0).toLocaleString()})
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* MD 제시가 — 입력 시 목표가로 사용. 상품에 다른 후보가 없으면 유일 후보라 필수. */}
|
{/* MD 제시가 — 입력 시 목표가로 사용. 상품에 다른 후보가 없으면 유일 후보라 필수. */}
|
||||||
@ -328,30 +387,19 @@ export function QuotationCreateModal({
|
|||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Typography as="span" variant="label" className="block">협력사 초청 ({oneToOne ? '단일선택' : '다중선택'})</Typography>
|
<Typography as="span" variant="label" className="block">협력사 초청 ({oneToOne ? '단일선택' : '다중선택'})</Typography>
|
||||||
<div className="border border-border rounded overflow-hidden max-h-56 overflow-y-auto divide-y divide-border bg-background">
|
{/* 서버검색 다중선택 — 각 행에 선택 상품 취급유형 배지(미매핑=미취급). oneToOne이면 togglePartner가 단일로 강제. */}
|
||||||
{partners.map((part) => {
|
<Combobox
|
||||||
const isChecked = selectedPartnerIds.includes(part.id ?? '');
|
variant="inline"
|
||||||
return (
|
multiple
|
||||||
<label
|
values={selectedPartnerIds}
|
||||||
key={part.id}
|
options={supplierOptions}
|
||||||
className="flex items-center justify-between p-3 hover:bg-muted/30 cursor-pointer transition-colors"
|
loading={supplierSearch.isLoading}
|
||||||
>
|
onQueryChange={setSupplierQ}
|
||||||
<div className="flex items-center gap-2.5">
|
onToggle={(opt) => togglePartner(opt.id)}
|
||||||
<input
|
searchPlaceholder="협력사명·코드·담당자 검색..."
|
||||||
type="checkbox"
|
emptyText="협력사가 없습니다"
|
||||||
checked={isChecked}
|
maxListHeight="max-h-56"
|
||||||
onChange={() => togglePartner(part.id ?? '')}
|
/>
|
||||||
className="accent-primary h-4 w-4"
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<Typography as="span" variant="small" className="font-semibold block">{part.name}</Typography>
|
|
||||||
<Typography as="span" variant="small" className="text-muted-foreground">이메일: {part.managerEmail}</Typography>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 협력사 유형 — 항상 노출(처음부터 입력 가능). 재협상(1:1)이면 선택 협력사의 직전 견적 값으로 자동 디폴트. */}
|
{/* 협력사 유형 — 항상 노출(처음부터 입력 가능). 재협상(1:1)이면 선택 협력사의 직전 견적 값으로 자동 디폴트. */}
|
||||||
<div className="space-y-1 pt-3 border-t border-border/40">
|
<div className="space-y-1 pt-3 border-t border-border/40">
|
||||||
@ -454,35 +502,18 @@ export function QuotationCreateModal({
|
|||||||
<Typography as="span" variant="small" className="block text-[10px] text-muted-foreground">
|
<Typography as="span" variant="small" className="block text-[10px] text-muted-foreground">
|
||||||
1:1 협상에서 AI 협상봇이 발동할 카드입니다.
|
1:1 협상에서 AI 협상봇이 발동할 카드입니다.
|
||||||
</Typography>
|
</Typography>
|
||||||
<div className="grid grid-cols-2 gap-2 max-h-72 overflow-y-auto">
|
<Combobox
|
||||||
{cards.filter((c) => !c.isWildcard || c.status === 'ACTIVE').map((card) => {
|
variant="inline"
|
||||||
const isChecked = selectedCardIds.includes(card.id);
|
multiple
|
||||||
return (
|
values={selectedCardIds}
|
||||||
<div
|
options={cardOptions}
|
||||||
key={card.id}
|
loading={cardSearch.isLoading}
|
||||||
onClick={() => toggleCard(card.id)}
|
onQueryChange={setCardQ}
|
||||||
className={`p-2.5 rounded border cursor-pointer transition-all flex items-start gap-2 ${
|
onToggle={(opt) => toggleCard(opt.id)}
|
||||||
isChecked ? 'bg-primary/5 border-primary font-bold' : 'bg-background border-border hover:bg-muted/10'
|
searchPlaceholder="카드명·번호·스크립트 검색..."
|
||||||
}`}
|
emptyText="협상카드가 없습니다"
|
||||||
>
|
maxListHeight="max-h-72"
|
||||||
<input type="checkbox" checked={isChecked} readOnly className="accent-primary h-3.5 w-3.5 mt-0.5" />
|
/>
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<Typography as="span" variant="small" className="text-muted-foreground font-mono block leading-none">{card.code}</Typography>
|
|
||||||
<span
|
|
||||||
className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${
|
|
||||||
card.isWildcard ? 'bg-amber-50 text-amber-700' : 'bg-zinc-100 text-zinc-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{card.isWildcard ? '와일드' : '협상'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Typography as="span" variant="small" className="mt-1 block leading-tight">{card.title}</Typography>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -529,6 +560,22 @@ export function QuotationCreateModal({
|
|||||||
|
|
||||||
// ── 헬퍼 컴포넌트 (메인 아래) ──────────────────────────────────────────────
|
// ── 헬퍼 컴포넌트 (메인 아래) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
// 협력사 취급유형 배지 — type undefined = 이 상품 미취급, 그 외 SupplierType 라벨(제조/유통/총판/없음).
|
||||||
|
function SupplyTypeBadge({ type }: { type?: number }) {
|
||||||
|
if (type === undefined) {
|
||||||
|
return (
|
||||||
|
<Typography as="span" variant="small" className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground shrink-0">
|
||||||
|
미취급
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Typography as="span" variant="small" className="text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary font-semibold shrink-0">
|
||||||
|
{supplierTypeLabel(type)}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 세그먼트 컨트롤 — 소수의 명명된 이산 선택(진행 방식·대상)에 라디오보다 명확. 값은 문자열.
|
// 세그먼트 컨트롤 — 소수의 명명된 이산 선택(진행 방식·대상)에 라디오보다 명확. 값은 문자열.
|
||||||
function Segmented({
|
function Segmented({
|
||||||
options,
|
options,
|
||||||
|
|||||||
@ -180,6 +180,16 @@ CREATE TABLE IF NOT EXISTS partner.item_internet_lowest_prices (
|
|||||||
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
|
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS partner.supplier_items (
|
||||||
|
supplier_item_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 매핑 식별자(PK)
|
||||||
|
supplier_id uuid NOT NULL, -- 협력사(partner.suppliers.supplier_id)
|
||||||
|
item_id uuid NOT NULL, -- 상품(partner.items.item_id)
|
||||||
|
supply_type SMALLINT NOT NULL DEFAULT 0, -- 공급 유형(SupplierType): 0=none(없음), 1=distribution(유통), 2=manufacture(제조), 3=sole_agency(총판)
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
|
||||||
|
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
|
||||||
|
);
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- card : 협상 전략 (버전 / 협상카드 / 와일드카드 / 매핑)
|
-- card : 협상 전략 (버전 / 협상카드 / 와일드카드 / 매핑)
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
@ -370,6 +380,8 @@ CREATE INDEX IF NOT EXISTS idx_suppliers_user_id ON partner.suppliers
|
|||||||
CREATE INDEX IF NOT EXISTS idx_items_company_id ON partner.items (company_id);
|
CREATE INDEX IF NOT EXISTS idx_items_company_id ON partner.items (company_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_items_user_id ON partner.items (user_id);
|
CREATE INDEX IF NOT EXISTS idx_items_user_id ON partner.items (user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_iilp_item_id ON partner.item_internet_lowest_prices (item_id);
|
CREATE INDEX IF NOT EXISTS idx_iilp_item_id ON partner.item_internet_lowest_prices (item_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_supplier_items_supplier_id ON partner.supplier_items (supplier_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_supplier_items_item_id ON partner.supplier_items (item_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_versions_user_id ON card.versions (user_id);
|
CREATE INDEX IF NOT EXISTS idx_versions_user_id ON card.versions (user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_nego_cards_user_id ON card.nego_cards (user_id);
|
CREATE INDEX IF NOT EXISTS idx_nego_cards_user_id ON card.nego_cards (user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_wild_cards_user_id ON card.wild_cards (user_id);
|
CREATE INDEX IF NOT EXISTS idx_wild_cards_user_id ON card.wild_cards (user_id);
|
||||||
@ -392,6 +404,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_supplier_users_id ON supplier.supplier_
|
|||||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_companies_biz_number ON company.companies (business_number) WHERE deleted = FALSE AND business_number IS NOT NULL;
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_companies_biz_number ON company.companies (business_number) WHERE deleted = FALSE AND business_number IS NOT NULL;
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_quotations_number ON quotation.quotations (number, round) WHERE deleted = FALSE;
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_quotations_number ON quotation.quotations (number, round) WHERE deleted = FALSE;
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_chats_session_seq ON negotiation.chats (session_id, seq) WHERE deleted = FALSE; -- 세션 내 메시지 순번 유니크 (session 1:N, session_id 조회도 이 인덱스로 커버)
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_chats_session_seq ON negotiation.chats (session_id, seq) WHERE deleted = FALSE; -- 세션 내 메시지 순번 유니크 (session 1:N, session_id 조회도 이 인덱스로 커버)
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_supplier_items ON partner.supplier_items (supplier_id, item_id) WHERE deleted = FALSE; -- (협력사,상품) 매핑 중복 방지(소프트 삭제분은 재등록 허용)
|
||||||
|
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
@ -102,3 +102,19 @@ ALTER TABLE partner.suppliers
|
|||||||
ADD COLUMN IF NOT EXISTS total_revenue BIGINT; -- 총매출액(원, KTC total_revenue 미러)
|
ADD COLUMN IF NOT EXISTS total_revenue BIGINT; -- 총매출액(원, KTC total_revenue 미러)
|
||||||
ALTER TABLE partner.suppliers
|
ALTER TABLE partner.suppliers
|
||||||
DROP COLUMN IF EXISTS priority;
|
DROP COLUMN IF EXISTS priority;
|
||||||
|
|
||||||
|
-- ─────────────────────────────────────────────────────────────
|
||||||
|
-- [2026-07-07] 협력사 취급상품 매핑: (협력사, 상품) + 공급유형(SupplierType).
|
||||||
|
-- (신규 DB 는 01-schema*.sql 에 반영됨.)
|
||||||
|
CREATE TABLE IF NOT EXISTS partner.supplier_items (
|
||||||
|
supplier_item_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
supplier_id uuid NOT NULL, -- 협력사(partner.suppliers.supplier_id)
|
||||||
|
item_id uuid NOT NULL, -- 상품(partner.items.item_id)
|
||||||
|
supply_type SMALLINT NOT NULL DEFAULT 0, -- 공급 유형(SupplierType): 0=없음/1=유통/2=제조/3=총판
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
deleted BOOLEAN NOT NULL DEFAULT FALSE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_supplier_items_supplier_id ON partner.supplier_items (supplier_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_supplier_items_item_id ON partner.supplier_items (item_id);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_supplier_items ON partner.supplier_items (supplier_id, item_id) WHERE deleted = FALSE;
|
||||||
Loading…
Reference in New Issue
Block a user