[feat] negodata: 신규 견적유형 + 목표가·앵커링 산정 + 협력사유형 + 협상카드 용도
- 견적유형 4종: QuotationType 에 신규협상(3)·신규견적(4) 추가(1·2 재 고정)
- 목표가 산정: items(매입가·판매가·인터넷최저가) + quotations.md_price +
quotation_settings.internet_average_fee 로 _calc_target_price(KTC 이식, 신규/재 분리),
세션 target_price·target_anchoring_price 저장
- 협력사유형: quotations.supplier_type(SupplierType) + 직전유형 조회 API(/supplier/{id}/last-type)
- 협상카드 용도: nego_cards/wild_cards.usage_type(CardUsageType: 공통/신규견적전용/재견적전용)
- 스키마(01-schema.sql)·ORM·protocol·service·crud·프론트(생성타입/폼) 일괄, /backend enums·models 미러
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
21e9f71f53
commit
103c80046e
@ -85,6 +85,9 @@ class items(MAIN_BASE):
|
||||
vat_yn = Column(Boolean, nullable=True) # 부가세 포함 여부
|
||||
delivery_fee_yn = Column(Boolean, nullable=True) # 배송비 포함 여부
|
||||
internet_lowest_price_yn = Column(Boolean, nullable=False, server_default=text("false")) # 최저가 솔루션 보조 컬럼
|
||||
internet_lowest_price = Column(BigInteger, nullable=True) # 인터넷 최저가 실값(원). 목표가 산정용(KTC: ×(1−수수료))
|
||||
purchase_price = Column(BigInteger, nullable=True) # 매입가(원). 목표가 후보(그대로). KTC items.purchase_price
|
||||
selling_price = Column(BigInteger, nullable=True) # 판매가(원). 목표가 후보(×(1−마진율)), 유통형만. KTC items.selling_price
|
||||
category_type = Column(Integer, nullable=False, server_default=text("1")) # 카테고리 조회용 자동 증가 숫자
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, 앱에서 갱신)
|
||||
@ -108,6 +111,7 @@ class sessions(MAIN_BASE):
|
||||
qt_round = Column(Integer, nullable=False) # 견적 라운드(스냅샷)
|
||||
qt_type = Column(SmallInteger, nullable=False) # 견적 유형: 1=재협상, 2=재견적 (QtType)
|
||||
target_price = Column(BigInteger, nullable=False) # 목표가(원)
|
||||
target_anchoring_price = Column(BigInteger, nullable=True) # 앵커링가(원)=floor(목표가×(1−anchoring_value)). KTC sessions.target_anchoring_price
|
||||
status = Column(SmallInteger, nullable=False) # 진행 상태 (SessionStatus 코드)
|
||||
bid_price = Column(BigInteger, nullable=True) # 입찰가(원)
|
||||
bid_at = Column(DateTime(timezone=True), nullable=True) # 입찰 시각
|
||||
@ -144,6 +148,8 @@ class quotations(MAIN_BASE):
|
||||
manager_email = Column(String(255), nullable=True) # 담당자 이메일
|
||||
manager_contact_number = Column(String(20), nullable=True) # 담당자 연락처
|
||||
memo = Column(String(100), nullable=True) # 메모
|
||||
md_price = Column(BigInteger, nullable=True) # MD 제시가(원). 목표가 산정 최우선값
|
||||
supplier_type = Column(SmallInteger, nullable=True) # 협력사 유형(SupplierType). 재견적 1:1 → 견적에 기록
|
||||
iteration = Column(Integer, nullable=False, server_default=text("0")) # 반복 횟수
|
||||
preferred_sp_yn = Column(Boolean, nullable=True) # 선호 공급사 지정 여부
|
||||
preferred_sp_id = Column(UUID(as_uuid=True), nullable=True) # 선호 공급사(partner.suppliers.supplier_id)
|
||||
@ -168,6 +174,7 @@ class quotation_settings(MAIN_BASE):
|
||||
user_id = Column(UUID(as_uuid=True), nullable=False) # 생성 유저(company.users.user_id)
|
||||
target_margin_rate = Column(Numeric(8, 6), nullable=False) # 목표 마진율
|
||||
anchoring_value = Column(Numeric(8, 6), nullable=False, server_default=text("0.01")) # 앵커링 값(비율) — anchor=round(target*(1-value))
|
||||
internet_average_fee = Column(Numeric(8, 6), nullable=False, server_default=text("0.078")) # 인터넷 평균 수수료율. 목표가=인터넷최저가×(1−값). KTC QuotationPrice.internet_average_fee(Float)
|
||||
card_count = Column(Integer, nullable=False, server_default=text("3")) # 협상 내 협상카드 사용 횟수
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, 앱에서 갱신)
|
||||
|
||||
@ -107,10 +107,13 @@ class TokenType(Enum):
|
||||
|
||||
|
||||
class QtType(Enum):
|
||||
"""견적/세션 유형 코드. quotation.quotations.type / negotiation.sessions.qt_type."""
|
||||
"""견적/세션 유형 코드. quotation.quotations.type / negotiation.sessions.qt_type.
|
||||
신규/재 × 협상(1:1)/견적(1:N). 1·2(재)는 기존 데이터 보존 위해 고정, 신규는 3·4."""
|
||||
|
||||
RENEGO = 1 # 재협상(1:1)
|
||||
REQUOTE = 2 # 재견적(1:N)
|
||||
RENEGO = 1 # 재협상(1:1)
|
||||
REQUOTE = 2 # 재견적(1:N)
|
||||
NEW_NEGO = 3 # 신규협상(1:1)
|
||||
NEW_QUOTE = 4 # 신규견적(1:N)
|
||||
|
||||
|
||||
class SessionStatus(Enum):
|
||||
|
||||
@ -86,6 +86,9 @@ class items(MainTableMixin, MAIN_BASE):
|
||||
|
||||
price = Column(BigInteger, nullable=True) # 금액(원), 스키마 BIGINT
|
||||
internet_lowest_price_yn = Column(Boolean, nullable=False, default=False) # 최저가 솔루션 원자성 보존용
|
||||
internet_lowest_price = Column(BigInteger, nullable=True) # 인터넷 최저가 실값(원). 목표가 산정용(KTC: ×(1−수수료))
|
||||
purchase_price = Column(BigInteger, nullable=True) # 매입가(원). 목표가 후보(그대로). KTC items.purchase_price(Integer)
|
||||
selling_price = Column(BigInteger, nullable=True) # 판매가(원). 목표가 후보(×(1−마진율)), 유통형만. KTC items.selling_price(Integer)
|
||||
|
||||
moq = Column(String(50), nullable=True) # 최소 주문 수량
|
||||
lead_time = Column(SmallInteger, nullable=True) # 주문 후 배송 도착까지 시간
|
||||
@ -121,6 +124,7 @@ class nego_cards(MainTableMixin, MAIN_BASE):
|
||||
number = Column(String(10), nullable=True) # 식별번호(카드코드)
|
||||
script = Column(String(255), nullable=True) # 협상 스크립트(평문 미리보기)
|
||||
edit_script = Column(JSONB, nullable=True) # 편집된 스크립트(Slate JSON)
|
||||
usage_type = Column(SmallInteger, nullable=False, default=1) # 카드 적용 견적 구분(CardUsageType): 1=공통 2=신규견적전용 3=재견적전용
|
||||
|
||||
|
||||
class wild_cards(MainTableMixin, MAIN_BASE):
|
||||
@ -133,6 +137,7 @@ class wild_cards(MainTableMixin, MAIN_BASE):
|
||||
number = Column(String(10), nullable=True) # 식별번호(카드코드)
|
||||
script = Column(String(255), nullable=True) # 협상 스크립트(평문 미리보기)
|
||||
edit_script = Column(JSONB, nullable=True) # 편집된 스크립트(Slate JSON)
|
||||
usage_type = Column(SmallInteger, nullable=False, default=1) # 카드 적용 견적 구분(CardUsageType): 1=공통 2=신규견적전용 3=재견적전용
|
||||
condition = Column(String(255), nullable=True) # 사용 조건(트리거)
|
||||
available = Column(Boolean, nullable=False, default=False) # 수동 협상 적용 여부(ACTIVE/INACTIVE 매핑)
|
||||
memo = Column(String(255), nullable=True) # 자유 메모
|
||||
@ -174,6 +179,7 @@ class quotation_settings(MainTableMixin, MAIN_BASE):
|
||||
user_id = Column(UUID(as_uuid=True), nullable=True, index=True) # 설정 소유 유저
|
||||
target_margin_rate = Column(Numeric(8, 6), nullable=False)
|
||||
anchoring_value = Column(Numeric(8, 6), nullable=False, default=0.01)
|
||||
internet_average_fee = Column(Numeric(8, 6), nullable=False, default=0.078) # 인터넷 평균 수수료율. 목표가=인터넷최저가×(1−값). KTC QuotationPrice.internet_average_fee(Float)
|
||||
card_count = Column(Integer, nullable=False, default=3) # 한 협상 내 협상카드 사용 횟수
|
||||
|
||||
|
||||
@ -198,6 +204,8 @@ class quotations(MainTableMixin, MAIN_BASE):
|
||||
manager_email = Column(String(255), nullable=True)
|
||||
manager_contact_number = Column(String(20), nullable=True)
|
||||
memo = Column(String(100), nullable=True)
|
||||
md_price = Column(BigInteger, nullable=True) # MD 제시가(원). 목표가 산정 최우선값 — 입력은 견적생성 모달
|
||||
supplier_type = Column(SmallInteger, nullable=True) # 협력사 유형(SupplierType). 재견적은 1:1이라 견적에 박는다. 입력은 견적생성 모달
|
||||
|
||||
iteration = Column(Integer, nullable=False, default=0)
|
||||
preferred_sp_yn = Column(Boolean, nullable=True)
|
||||
@ -220,6 +228,7 @@ class sessions(MainTableMixin, MAIN_BASE):
|
||||
qt_round = Column(Integer, nullable=False) # 견적 라운드 스냅샷
|
||||
qt_type = Column(SmallInteger, nullable=False) # QuotationType 스냅샷
|
||||
target_price = Column(BigInteger, nullable=False) # 목표가(원)
|
||||
target_anchoring_price = Column(BigInteger, nullable=True) # 앵커링가(원)=floor(목표가×(1−anchoring_value)). KTC sessions.target_anchoring_price(BigInteger)
|
||||
status = Column(SmallInteger, nullable=False) # SessionStatus 코드
|
||||
bid_price = Column(BigInteger, nullable=True) # 입찰가(원)
|
||||
bid_at = Column(DateTime(timezone=True), nullable=True) # 입찰 시각
|
||||
|
||||
@ -58,6 +58,7 @@ class ErrorType(Enum):
|
||||
# 견적 관련 에러
|
||||
QUOTATION_NOT_FOUND = 1500
|
||||
QUOTATION_NOT_LATEST_ROUND = auto() # 마지막 차수가 아닌 견적을 재생성하려 함
|
||||
QUOTATION_TARGET_PRICE_UNAVAILABLE = auto() # md_price·인터넷최저가 둘 다 없어 목표가 산정 불가
|
||||
|
||||
# 견적 설정 관련 에러
|
||||
QUOTATION_SETTING_NOT_FOUND = 1600
|
||||
@ -118,10 +119,14 @@ class CompanyStatus(CodeEnum):
|
||||
|
||||
|
||||
class QuotationType(CodeEnum):
|
||||
"""quotations.type 코드값. 1=renego(재협상 1:1), 2=requote(재견적 1:N)."""
|
||||
"""quotations.type 코드값. 신규/재 × 협상(1:1)/견적(1:N).
|
||||
1=재협상(1:1), 2=재견적(1:N), 3=신규협상(1:1), 4=신규견적(1:N).
|
||||
기존 데이터 보존 위해 재협상/재견적 코드(1·2)는 고정, 신규는 3·4로 추가."""
|
||||
|
||||
RENEGO = 1
|
||||
REQUOTE = 2
|
||||
RENEGO = 1 # 재협상(1:1)
|
||||
REQUOTE = 2 # 재견적(1:N)
|
||||
NEW_NEGO = 3 # 신규협상(1:1)
|
||||
NEW_QUOTE = 4 # 신규견적(1:N)
|
||||
|
||||
|
||||
class QuotationStatus(CodeEnum):
|
||||
@ -179,3 +184,22 @@ class CardType(CodeEnum):
|
||||
|
||||
NEGO = 1
|
||||
WILD = 2
|
||||
|
||||
|
||||
class SupplierType(CodeEnum):
|
||||
"""quotations.supplier_type 코드값. 없음(0,미지정)/유통(1)/제조(2)/총판(3).
|
||||
없음은 프론트 폼에 '없음'으로 노출. KTC 앵커링 코드(기타=0)와 매핑 시 0↔없음 대응."""
|
||||
|
||||
NONE = 0 # 없음(미지정)
|
||||
DISTRIBUTION = 1 # 유통
|
||||
MANUFACTURE = 2 # 제조
|
||||
SOLE_AGENCY = 3 # 총판
|
||||
|
||||
|
||||
class CardUsageType(CodeEnum):
|
||||
"""nego_cards/wild_cards.usage_type 코드값. 협상카드 사용 범위(신규/재 견적·협상 양쪽 적용).
|
||||
공통=모두 적용(기본), 신규견적전용, 재견적전용."""
|
||||
|
||||
COMMON = 1 # 공통(모두) — 기본
|
||||
NEW = 2 # 신규견적전용
|
||||
REUSE = 3 # 재견적전용
|
||||
|
||||
@ -40,7 +40,11 @@ class IQuotationCRUD(ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_target_margin(self, cdb: AsyncSession, qt_setting_id) -> Tuple[ErrorType, Optional[float]]:
|
||||
async def get_last_supplier_type(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, Optional[tuple]]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_setting_rates(self, cdb: AsyncSession, qt_setting_id) -> Tuple[ErrorType, dict]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
@ -313,35 +317,71 @@ class QuotationCRUD(IQuotationCRUD):
|
||||
return ErrorType.DB_RUN_FAILED, []
|
||||
|
||||
async def get_item_prices(self, cdb: AsyncSession, item_ids) -> Tuple[ErrorType, dict]:
|
||||
"""item_id -> price(원, NULL 가능) 매핑. 세션 목표가 계산 입력."""
|
||||
"""item_id -> (internet_lowest_price, purchase_price, selling_price)(원, NULL 가능) 매핑. 세션 목표가 계산 입력."""
|
||||
try:
|
||||
if not item_ids:
|
||||
return ErrorType.SUCCESS, {}
|
||||
query = select(items.item_id, items.price).where(
|
||||
query = select(
|
||||
items.item_id, items.internet_lowest_price, items.purchase_price, items.selling_price
|
||||
).where(
|
||||
items.item_id.in_(item_ids), items.deleted == False # noqa: E712
|
||||
)
|
||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
return err_type, {}
|
||||
return ErrorType.SUCCESS, {r[0]: r[1] for r in rows}
|
||||
return ErrorType.SUCCESS, {r[0]: (r[1], r[2], r[3]) for r in rows}
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, {}
|
||||
|
||||
async def get_target_margin(self, cdb: AsyncSession, qt_setting_id) -> Tuple[ErrorType, Optional[float]]:
|
||||
"""견적 세팅의 목표 마진율. 세션 목표가 = price / (1 + margin)."""
|
||||
async def get_last_supplier_type(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, Optional[tuple]]:
|
||||
"""협력사의 직전 견적 supplier_type. (supplier_type, qt_number) | None.
|
||||
sessions(supplier_id) ⨝ quotations 에서 supplier_type 가 있는 최신 견적 1건."""
|
||||
try:
|
||||
query = select(quotation_settings.target_margin_rate).where(
|
||||
quotation_settings.qt_setting_id == qt_setting_id
|
||||
).limit(1)
|
||||
query = (
|
||||
select(quotations.supplier_type, quotations.number)
|
||||
.join(sessions, sessions.quotation_id == quotations.qt_id)
|
||||
.where(
|
||||
sessions.supplier_id == supplier_id,
|
||||
quotations.supplier_type.isnot(None),
|
||||
quotations.deleted == False, # noqa: E712
|
||||
)
|
||||
.order_by(quotations.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
return err_type, None
|
||||
return ErrorType.SUCCESS, (float(rows[0]) if rows and rows[0] is not None else None)
|
||||
if not rows:
|
||||
return ErrorType.SUCCESS, None
|
||||
return ErrorType.SUCCESS, (rows[0][0], rows[0][1])
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, None
|
||||
|
||||
async def get_setting_rates(self, cdb: AsyncSession, qt_setting_id) -> Tuple[ErrorType, dict]:
|
||||
"""견적 세팅의 율: {margin, fee, anchoring}. 목표가·앵커링가 산정 입력."""
|
||||
try:
|
||||
query = select(
|
||||
quotation_settings.target_margin_rate,
|
||||
quotation_settings.internet_average_fee,
|
||||
quotation_settings.anchoring_value,
|
||||
).where(quotation_settings.qt_setting_id == qt_setting_id).limit(1)
|
||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
return err_type, {}
|
||||
if not rows:
|
||||
return ErrorType.SUCCESS, {}
|
||||
r = rows[0]
|
||||
return ErrorType.SUCCESS, {
|
||||
"margin": float(r[0]) if r[0] is not None else None,
|
||||
"fee": float(r[1]) if r[1] is not None else None,
|
||||
"anchoring": float(r[2]) if r[2] is not None else None,
|
||||
}
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, {}
|
||||
|
||||
async def update_quotation(self, cdb: AsyncSession, qt_id, data: dict) -> ErrorType:
|
||||
try:
|
||||
if not data:
|
||||
|
||||
@ -4,7 +4,7 @@ from typing import Any, Optional
|
||||
|
||||
from pydantic import ConfigDict
|
||||
|
||||
from common.enums import CardStatus
|
||||
from common.enums import CardStatus, CardUsageType
|
||||
from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol
|
||||
|
||||
|
||||
@ -18,6 +18,7 @@ class Req_CreateCard(CardProtocol):
|
||||
number: Optional[str] = None
|
||||
script: Optional[str] = None
|
||||
edit_script: Optional[Any] = None
|
||||
usage_type: int = CardUsageType.COMMON.value # 카드 적용 견적 구분(CardUsageType): 1=공통 2=신규견적전용 3=재견적전용
|
||||
status: int = CardStatus.ACTIVE.value # 와일드카드 적용 여부(available 매핑). 일반카드는 무시.
|
||||
condition: Optional[str] = None # 와일드카드 전용
|
||||
memo: Optional[str] = None # 와일드카드 전용
|
||||
@ -28,6 +29,7 @@ class Req_UpdateCard(CardProtocol):
|
||||
number: Optional[str] = None
|
||||
script: Optional[str] = None
|
||||
edit_script: Optional[Any] = None
|
||||
usage_type: Optional[int] = None # 카드 적용 견적 구분(CardUsageType): 1=공통 2=신규견적전용 3=재견적전용
|
||||
status: Optional[int] = None
|
||||
condition: Optional[str] = None
|
||||
memo: Optional[str] = None
|
||||
@ -44,6 +46,7 @@ class CardData(WebPacketProtocol):
|
||||
number: Optional[str] = None
|
||||
script: Optional[str] = None
|
||||
edit_script: Optional[Any] = None
|
||||
usage_type: CardUsageType = CardUsageType.COMMON # 카드 적용 견적 구분: 1=공통 2=신규견적전용 3=재견적전용
|
||||
status: CardStatus = CardStatus.ACTIVE
|
||||
condition: Optional[str] = None
|
||||
memo: Optional[str] = None
|
||||
|
||||
@ -24,6 +24,9 @@ class Req_CreateItem(ItemProtocol):
|
||||
made_in: Optional[str] = None
|
||||
price: Optional[int] = None
|
||||
internet_lowest_price_yn: bool = False
|
||||
internet_lowest_price: Optional[int] = None # 인터넷 최저가 실값(원). 목표가 산정용
|
||||
purchase_price: Optional[int] = None # 매입가(원). 재견적 목표가 후보(그대로)
|
||||
selling_price: Optional[int] = None # 판매가(원). 유통형 목표가 후보(×(1−마진율))
|
||||
moq: Optional[str] = None
|
||||
lead_time: Optional[int] = None
|
||||
quantity_unit: Optional[str] = None
|
||||
@ -44,6 +47,9 @@ class Req_UpdateItem(ItemProtocol):
|
||||
made_in: Optional[str] = None
|
||||
price: Optional[int] = None
|
||||
internet_lowest_price_yn: Optional[bool] = None
|
||||
internet_lowest_price: Optional[int] = None # 인터넷 최저가 실값(원). 목표가 산정용
|
||||
purchase_price: Optional[int] = None # 매입가(원). 재견적 목표가 후보(그대로)
|
||||
selling_price: Optional[int] = None # 판매가(원). 유통형 목표가 후보(×(1−마진율))
|
||||
moq: Optional[str] = None
|
||||
lead_time: Optional[int] = None
|
||||
quantity_unit: Optional[str] = None
|
||||
@ -69,6 +75,9 @@ class ItemData(WebPacketProtocol):
|
||||
made_in: Optional[str] = None
|
||||
price: Optional[int] = None
|
||||
internet_lowest_price_yn: bool = False
|
||||
internet_lowest_price: Optional[int] = None # 인터넷 최저가 실값(원). 목표가 산정용
|
||||
purchase_price: Optional[int] = None # 매입가(원). 재견적 목표가 후보(그대로)
|
||||
selling_price: Optional[int] = None # 판매가(원). 유통형 목표가 후보(×(1−마진율))
|
||||
moq: Optional[str] = None
|
||||
lead_time: Optional[int] = None
|
||||
quantity_unit: Optional[str] = None
|
||||
|
||||
@ -4,7 +4,7 @@ from typing import Any, Optional
|
||||
|
||||
from pydantic import ConfigDict
|
||||
|
||||
from common.enums import CardType, ChatSender, DeliveryType, QuotationStatus, QuotationType, SessionStatus
|
||||
from common.enums import CardType, ChatSender, DeliveryType, QuotationStatus, QuotationType, SessionStatus, SupplierType
|
||||
from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol
|
||||
|
||||
|
||||
@ -25,6 +25,8 @@ class Req_CreateQuotation(QuotationProtocol):
|
||||
manager_email: Optional[str] = None
|
||||
manager_contact_number: Optional[str] = None
|
||||
memo: Optional[str] = None
|
||||
md_price: Optional[int] = None # MD 제시가(원). 세션 목표가 산정 최우선값
|
||||
supplier_type: Optional[int] = None # 협력사 유형(SupplierType). 재견적 1:1 → 견적에 기록
|
||||
item_ids: list[uuid.UUID] = [] # 협상 대상 상품. item×supplier 조합마다 세션 1개 생성
|
||||
supplier_ids: list[uuid.UUID] = [] # 협상 초청 공급사
|
||||
card_ids: list[uuid.UUID] = [] # 선택 협상카드. 버전을 만들어 묶고 quotation.version_id 로 연결
|
||||
@ -52,6 +54,8 @@ class QuotationData(WebPacketProtocol):
|
||||
manager_email: Optional[str] = None
|
||||
manager_contact_number: Optional[str] = None
|
||||
memo: Optional[str] = None
|
||||
md_price: Optional[int] = None
|
||||
supplier_type: Optional[SupplierType] = None
|
||||
iteration: int = 0
|
||||
preferred_sp_yn: Optional[bool] = None
|
||||
preferred_sp_id: Optional[uuid.UUID] = None
|
||||
@ -162,3 +166,8 @@ class QuotationCardData(WebPacketProtocol):
|
||||
class Res_QuotationCards(Res_WebPacketProtocol):
|
||||
qt_id: Optional[uuid.UUID] = None
|
||||
cards: list[QuotationCardData] = []
|
||||
|
||||
|
||||
class Res_LastSupplierType(Res_WebPacketProtocol):
|
||||
supplier_type: Optional[SupplierType] = None # 협력사 직전 견적의 유형(없으면 None)
|
||||
qt_number: Optional[str] = None # 그 견적의 번호(이전 견적 값임을 표시용)
|
||||
|
||||
@ -11,6 +11,7 @@ from .protocol import (
|
||||
Req_RegenerateQuotation,
|
||||
Res_CreateQuotation,
|
||||
Res_DeleteQuotation,
|
||||
Res_LastSupplierType,
|
||||
Res_Quotation,
|
||||
Res_QuotationCards,
|
||||
Res_QuotationList,
|
||||
@ -92,6 +93,11 @@ async def delete_quotation(qt_id: UUID, service: QuotationService = Depends(), u
|
||||
return RemoveNoneResponse(await service.delete_quotation(str(qt_id)))
|
||||
|
||||
|
||||
@router.get(path="/supplier/{supplier_id}/last-type", response_model=Res_LastSupplierType, summary="협력사 직전 견적 유형")
|
||||
async def get_supplier_last_type(supplier_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
return RemoveNoneResponse(await service.get_last_supplier_type(str(supplier_id)))
|
||||
|
||||
|
||||
# ----- 단건 조회 (정적/하위 경로 뒤에 선언) -----
|
||||
@router.get(path="/{qt_id}", response_model=Res_Quotation, summary="견적 조회")
|
||||
async def get_quotation(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
|
||||
@ -14,12 +14,14 @@ class QuotationSettingProtocol(WebPacketProtocol):
|
||||
class Req_CreateQuotationSetting(QuotationSettingProtocol):
|
||||
target_margin_rate: float
|
||||
anchoring_value: float = 0.01
|
||||
internet_average_fee: float = 0.078 # 인터넷 평균 수수료율(목표가 인터넷가 차감)
|
||||
card_count: int = 3
|
||||
|
||||
|
||||
class Req_UpdateQuotationSetting(QuotationSettingProtocol):
|
||||
target_margin_rate: Optional[float] = None
|
||||
anchoring_value: Optional[float] = None
|
||||
internet_average_fee: Optional[float] = None # 인터넷 평균 수수료율
|
||||
card_count: Optional[int] = None
|
||||
|
||||
|
||||
@ -30,6 +32,7 @@ class QuotationSettingData(WebPacketProtocol):
|
||||
user_id: Optional[uuid.UUID] = None
|
||||
target_margin_rate: float
|
||||
anchoring_value: float
|
||||
internet_average_fee: float = 0.078 # 인터넷 평균 수수료율
|
||||
card_count: int
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
@ -29,6 +29,7 @@ class CardService:
|
||||
number=row.number,
|
||||
script=row.script,
|
||||
edit_script=row.edit_script,
|
||||
usage_type=row.usage_type,
|
||||
status=CardStatus.ACTIVE.value,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
@ -45,6 +46,7 @@ class CardService:
|
||||
number=row.number,
|
||||
script=row.script,
|
||||
edit_script=row.edit_script,
|
||||
usage_type=row.usage_type,
|
||||
status=CardStatus.ACTIVE.value if row.available else CardStatus.INACTIVE.value,
|
||||
condition=row.condition,
|
||||
memo=row.memo,
|
||||
@ -146,6 +148,7 @@ class CardService:
|
||||
number=req.number,
|
||||
script=req.script,
|
||||
edit_script=req.edit_script,
|
||||
usage_type=req.usage_type,
|
||||
)
|
||||
if is_wildcard:
|
||||
card = wild_cards(
|
||||
@ -182,7 +185,7 @@ class CardService:
|
||||
return res
|
||||
|
||||
# 해당 테이블에 있는 컬럼만 추린다(없는 필드는 무시). status → available(와일드 전용).
|
||||
allowed = {"name", "number", "script", "edit_script"}
|
||||
allowed = {"name", "number", "script", "edit_script", "usage_type"}
|
||||
if is_wild:
|
||||
allowed |= {"condition", "memo"}
|
||||
payload = {k: v for k, v in data.items() if k in allowed}
|
||||
|
||||
@ -21,6 +21,7 @@ from router.v1.quotation.protocol import (
|
||||
Req_CreateQuotation,
|
||||
Res_CreateQuotation,
|
||||
Res_DeleteQuotation,
|
||||
Res_LastSupplierType,
|
||||
Res_Quotation,
|
||||
Res_QuotationCards,
|
||||
Res_QuotationList,
|
||||
@ -60,13 +61,29 @@ class QuotationService:
|
||||
return f"{base}/chat?session_id={session_id}"
|
||||
|
||||
@staticmethod
|
||||
def _calc_target_price(price, margin) -> int:
|
||||
"""세션 목표가(원). 단가 있으면 목표 마진율 적용가, 없으면 0."""
|
||||
if not price:
|
||||
return 0
|
||||
if margin and margin > 0:
|
||||
return int(int(price) / (1 + margin))
|
||||
return int(price)
|
||||
def _calc_target_price(md_price=None, internet_lowest=None, purchase=None, selling=None, fee=0.0, margin=0.0, is_new=False) -> int:
|
||||
"""세션 목표가 (KTC 신규/재 분리 로직, 회사 데이터 풍부도에 graceful 적응)
|
||||
① md_price 있으면 → 그대로
|
||||
② 없으면:
|
||||
· 신규(NEW_NEGO/NEW_QUOTE) → 인터넷최저가 × (1 − fee) [인터넷최저가만]
|
||||
· 재(RENEGO/REQUOTE) → 유효 후보 중 min:
|
||||
- 인터넷최저가 × (1 − fee) ← fee=quotation_settings.internet_average_fee
|
||||
- 매입가 (그대로)
|
||||
- 판매가 × (1 − margin) ← margin=quotation_settings.target_margin_rate
|
||||
③ 후보 0개 → 견적 생성 불가(ValueError)."""
|
||||
if md_price:
|
||||
return int(md_price)
|
||||
candidates = []
|
||||
if internet_lowest:
|
||||
candidates.append(int(internet_lowest) * (1 - (fee or 0.0)))
|
||||
if not is_new: # 재(협상·견적)만 매입가·판매가를 후보에 추가. 신규는 인터넷최저가만.
|
||||
if purchase:
|
||||
candidates.append(int(purchase))
|
||||
if selling:
|
||||
candidates.append(int(selling) * (1 - (margin or 0.0)))
|
||||
if not candidates:
|
||||
raise ValueError("타겟 가격 계산 불가: md_price·인터넷최저가" + ("" if is_new else "·매입가·판매가") + " 모두 없음")
|
||||
return int(min(candidates))
|
||||
|
||||
@staticmethod
|
||||
def _gen_number() -> str:
|
||||
@ -136,6 +153,22 @@ class QuotationService:
|
||||
res.quotation = QuotationData.model_validate(quotation)
|
||||
return res
|
||||
|
||||
async def get_last_supplier_type(self, supplier_id: str) -> Res_LastSupplierType:
|
||||
"""협력사의 직전 견적 supplier_type(견적생성 모달 프리필용). 이력 없으면 비워서 반환."""
|
||||
res = Res_LastSupplierType()
|
||||
err_type, got = await DB_SESSION_MNG.execute_lambda(
|
||||
quotations.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.quotation_crud.get_last_supplier_type(s, uuid.UUID(supplier_id)),
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
if got:
|
||||
res.supplier_type = got[0]
|
||||
res.qt_number = got[1]
|
||||
return res
|
||||
|
||||
async def create_quotation(self, user_id: str, req: Req_CreateQuotation) -> Res_CreateQuotation:
|
||||
"""[프론트] 신규 견적 생성. 요청값을 보정한 뒤 공통 빌더(_build_quotation)에 위임한다."""
|
||||
return await self._build_quotation(
|
||||
@ -153,6 +186,8 @@ class QuotationService:
|
||||
manager_email=req.manager_email,
|
||||
manager_contact_number=req.manager_contact_number,
|
||||
memo=req.memo,
|
||||
md_price=req.md_price,
|
||||
supplier_type=req.supplier_type,
|
||||
item_ids=req.item_ids,
|
||||
supplier_ids=req.supplier_ids,
|
||||
card_ids=req.card_ids,
|
||||
@ -182,6 +217,8 @@ class QuotationService:
|
||||
lambda s: self.quotation_crud.list_sessions(s, original_qt_id),
|
||||
)
|
||||
item_ids = list({r.item_id for r in rows}) if err_type == ErrorType.SUCCESS else []
|
||||
# 재생성은 목표가/앵커링가를 재계산하지 않고 직전 라운드 세션 값을 그대로 상속(KTC 방식).
|
||||
inherited = {r.item_id: (r.target_price, r.target_anchoring_price) for r in rows} if err_type == ErrorType.SUCCESS else {}
|
||||
|
||||
# 2) 타입 결정: 공급사 1곳 → 재협상 / 여러 곳 → 재견적
|
||||
next_type = QuotationType.RENEGO.value if len(supplier_ids) <= 1 else QuotationType.REQUOTE.value
|
||||
@ -218,22 +255,26 @@ class QuotationService:
|
||||
manager_email=original.manager_email,
|
||||
manager_contact_number=original.manager_contact_number,
|
||||
memo=original.memo,
|
||||
md_price=original.md_price,
|
||||
supplier_type=original.supplier_type,
|
||||
item_ids=item_ids,
|
||||
supplier_ids=list(supplier_ids),
|
||||
card_ids=[], # 새 버전 안 만듦(원본 version_id 재사용)
|
||||
inherited=inherited, # 직전 라운드 목표가·앵커링가 상속(재계산 안 함)
|
||||
)
|
||||
|
||||
async def _build_quotation(
|
||||
self, *,
|
||||
user_id: str, qt_setting_id, version_id, name: str, number: str,
|
||||
type_: int, status: int, round_: int, start_time, end_time,
|
||||
manager_name, manager_email, manager_contact_number, memo,
|
||||
manager_name, manager_email, manager_contact_number, memo, md_price, supplier_type,
|
||||
item_ids: list, supplier_ids: list, card_ids: list,
|
||||
inherited: Optional[dict] = None, # 재생성 시 {item_id: (target_price, target_anchoring_price)} 상속(KTC) — 있으면 재계산 안 함
|
||||
) -> Res_CreateQuotation:
|
||||
"""견적 1건 + (상품×공급사) 세션들을 한 트랜잭션으로 생성하는 공통 빌더."""
|
||||
res = Res_CreateQuotation()
|
||||
|
||||
# 세션 목표가 입력(상품 단가 + 견적 세팅 목표 마진율). 읽기 트랜잭션에서 먼저 조회.
|
||||
# 세션 목표가 입력(상품별 인터넷최저가/매입가/판매가 + 세팅 율). 읽기 트랜잭션에서 먼저 조회.
|
||||
prices = {}
|
||||
if item_ids:
|
||||
_err, prices = await DB_SESSION_MNG.execute_lambda(
|
||||
@ -242,12 +283,15 @@ class QuotationService:
|
||||
lambda s: self.quotation_crud.get_item_prices(s, item_ids),
|
||||
)
|
||||
prices = prices if _err == ErrorType.SUCCESS else {}
|
||||
_err, margin = await DB_SESSION_MNG.execute_lambda(
|
||||
_err, rates = await DB_SESSION_MNG.execute_lambda(
|
||||
quotations.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.quotation_crud.get_target_margin(s, qt_setting_id),
|
||||
lambda s: self.quotation_crud.get_setting_rates(s, qt_setting_id),
|
||||
)
|
||||
margin = margin if _err == ErrorType.SUCCESS else None
|
||||
rates = rates if _err == ErrorType.SUCCESS else {}
|
||||
fee = rates.get("fee") or 0.0 # 인터넷가 차감 수수료율
|
||||
margin = rates.get("margin") or 0.0 # 판매가 차감 목표마진율
|
||||
anchoring = rates.get("anchoring") or 0.0 # 앵커링가 = 목표가×(1−값)
|
||||
|
||||
# 선택 협상카드가 있으면 새 버전을 만들어 카드들을 묶고, quotation.version_id 로 연결한다.
|
||||
# (quotation↔card 는 version → version_nego_cards/version_wild_cards 로 연결.)
|
||||
@ -293,27 +337,41 @@ class QuotationService:
|
||||
manager_email=manager_email,
|
||||
manager_contact_number=manager_contact_number,
|
||||
memo=memo,
|
||||
md_price=md_price,
|
||||
supplier_type=supplier_type,
|
||||
)
|
||||
|
||||
# 상품 × 공급사 조합마다 세션 1개.
|
||||
# 상품 × 공급사 조합마다 세션 1개. md/매입/판매/인터넷 후보가 하나도 없으면 목표가 산정 불가 → 생성 실패.
|
||||
# 신규(NEW_NEGO/NEW_QUOTE)는 인터넷최저가만, 재(RENEGO/REQUOTE)는 매입가·판매가까지 후보(KTC 신규/재 분리).
|
||||
is_new = type_ in (QuotationType.NEW_NEGO.value, QuotationType.NEW_QUOTE.value)
|
||||
session_objs = []
|
||||
for iid in item_ids:
|
||||
tp = self._calc_target_price(prices.get(iid), margin)
|
||||
for sid in supplier_ids:
|
||||
session_objs.append(
|
||||
sessions(
|
||||
session_id=uuid.uuid4(),
|
||||
quotation_id=qt_id,
|
||||
item_id=iid,
|
||||
supplier_id=sid,
|
||||
qt_number=quotation.number,
|
||||
qt_round=quotation.round,
|
||||
qt_type=quotation.type,
|
||||
target_price=tp,
|
||||
status=SessionStatus.CREATED.value,
|
||||
end_time=quotation.end_time,
|
||||
try:
|
||||
for iid in item_ids:
|
||||
if inherited and iid in inherited:
|
||||
tp, ap = inherited[iid] # 재생성: 직전 라운드 목표가·앵커링가 그대로 상속(KTC) — 재계산 안 함
|
||||
else:
|
||||
internet, purchase, selling = prices.get(iid) or (None, None, None)
|
||||
tp = self._calc_target_price(md_price, internet, purchase, selling, fee, margin, is_new=is_new)
|
||||
ap = int(tp * (1 - anchoring)) # 앵커링가 = floor(목표가×(1−앵커링율)); 율 0이면 목표가와 동일
|
||||
for sid in supplier_ids:
|
||||
session_objs.append(
|
||||
sessions(
|
||||
session_id=uuid.uuid4(),
|
||||
quotation_id=qt_id,
|
||||
item_id=iid,
|
||||
supplier_id=sid,
|
||||
qt_number=quotation.number,
|
||||
qt_round=quotation.round,
|
||||
qt_type=quotation.type,
|
||||
target_price=tp,
|
||||
target_anchoring_price=ap,
|
||||
status=SessionStatus.CREATED.value,
|
||||
end_time=quotation.end_time,
|
||||
)
|
||||
)
|
||||
)
|
||||
except ValueError:
|
||||
res.result.SetResult(ErrorType.QUOTATION_TARGET_PRICE_UNAVAILABLE)
|
||||
return res
|
||||
|
||||
# 버전 → (버전-카드 매핑) → 견적 → 세션 순으로 한 트랜잭션에 insert(FK 순서 보장).
|
||||
ops = []
|
||||
|
||||
@ -66,6 +66,7 @@ class QuotationSettingService:
|
||||
user_id=uuid.UUID(user_id),
|
||||
target_margin_rate=req.target_margin_rate,
|
||||
anchoring_value=req.anchoring_value,
|
||||
internet_average_fee=req.internet_average_fee,
|
||||
card_count=req.card_count,
|
||||
)
|
||||
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||
|
||||
@ -9,6 +9,7 @@ import type { CardDataName } from './cardDataName';
|
||||
import type { CardDataNumber } from './cardDataNumber';
|
||||
import type { CardDataScript } from './cardDataScript';
|
||||
import type { CardDataEditScript } from './cardDataEditScript';
|
||||
import type { CardUsageType } from './cardUsageType';
|
||||
import type { CardStatus } from './cardStatus';
|
||||
import type { CardDataCondition } from './cardDataCondition';
|
||||
import type { CardDataMemo } from './cardDataMemo';
|
||||
@ -23,6 +24,7 @@ export interface CardData {
|
||||
number?: CardDataNumber;
|
||||
script?: CardDataScript;
|
||||
edit_script?: CardDataEditScript;
|
||||
usage_type?: CardUsageType;
|
||||
status?: CardStatus;
|
||||
condition?: CardDataCondition;
|
||||
memo?: CardDataMemo;
|
||||
|
||||
20
negodata/front/src/api/generated/model/cardUsageType.ts
Normal file
20
negodata/front/src/api/generated/model/cardUsageType.ts
Normal file
@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* nego_cards/wild_cards.usage_type 코드값. 카드가 적용되는 견적 구분.
|
||||
공통=신규·재 모두(기본), 신규전용=신규협상/신규견적(QuotationType 3·4), 재전용=재협상/재견적(1·2).
|
||||
*/
|
||||
export type CardUsageType = typeof CardUsageType[keyof typeof CardUsageType];
|
||||
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
||||
export const CardUsageType = {
|
||||
COMMON: 1,
|
||||
NEW: 2,
|
||||
REUSE: 3,
|
||||
} as const;
|
||||
@ -18,6 +18,7 @@ export * from './cardDataUpdatedAt';
|
||||
export * from './cardDataUserId';
|
||||
export * from './cardStatus';
|
||||
export * from './cardType';
|
||||
export * from './cardUsageType';
|
||||
export * from './chatMessageData';
|
||||
export * from './chatMessageDataCardId';
|
||||
export * from './chatMessageDataCardType';
|
||||
@ -41,13 +42,16 @@ export * from './itemDataCreatedAt';
|
||||
export * from './itemDataDeliveryFeeYn';
|
||||
export * from './itemDataDeliveryType';
|
||||
export * from './itemDataImageUrl';
|
||||
export * from './itemDataInternetLowestPrice';
|
||||
export * from './itemDataLeadTime';
|
||||
export * from './itemDataMadeIn';
|
||||
export * from './itemDataManufacturer';
|
||||
export * from './itemDataModelName';
|
||||
export * from './itemDataMoq';
|
||||
export * from './itemDataPrice';
|
||||
export * from './itemDataPurchasePrice';
|
||||
export * from './itemDataQuantityUnit';
|
||||
export * from './itemDataSellingPrice';
|
||||
export * from './itemDataSpec';
|
||||
export * from './itemDataUpdatedAt';
|
||||
export * from './itemDataVatYn';
|
||||
@ -75,10 +79,12 @@ export * from './quotationDataItemName';
|
||||
export * from './quotationDataManagerContactNumber';
|
||||
export * from './quotationDataManagerEmail';
|
||||
export * from './quotationDataManagerName';
|
||||
export * from './quotationDataMdPrice';
|
||||
export * from './quotationDataMemo';
|
||||
export * from './quotationDataPreferredSpId';
|
||||
export * from './quotationDataPreferredSpName';
|
||||
export * from './quotationDataPreferredSpYn';
|
||||
export * from './quotationDataSupplierType';
|
||||
export * from './quotationDataUpdatedAt';
|
||||
export * from './quotationSettingData';
|
||||
export * from './quotationSettingDataCreatedAt';
|
||||
@ -101,22 +107,27 @@ export * from './reqCreateItemCode';
|
||||
export * from './reqCreateItemDeliveryFeeYn';
|
||||
export * from './reqCreateItemDeliveryType';
|
||||
export * from './reqCreateItemImageUrl';
|
||||
export * from './reqCreateItemInternetLowestPrice';
|
||||
export * from './reqCreateItemLeadTime';
|
||||
export * from './reqCreateItemMadeIn';
|
||||
export * from './reqCreateItemManufacturer';
|
||||
export * from './reqCreateItemModelName';
|
||||
export * from './reqCreateItemMoq';
|
||||
export * from './reqCreateItemPrice';
|
||||
export * from './reqCreateItemPurchasePrice';
|
||||
export * from './reqCreateItemQuantityUnit';
|
||||
export * from './reqCreateItemSellingPrice';
|
||||
export * from './reqCreateItemSpec';
|
||||
export * from './reqCreateItemVatYn';
|
||||
export * from './reqCreateQuotation';
|
||||
export * from './reqCreateQuotationManagerContactNumber';
|
||||
export * from './reqCreateQuotationManagerEmail';
|
||||
export * from './reqCreateQuotationManagerName';
|
||||
export * from './reqCreateQuotationMdPrice';
|
||||
export * from './reqCreateQuotationMemo';
|
||||
export * from './reqCreateQuotationSetting';
|
||||
export * from './reqCreateQuotationStartTime';
|
||||
export * from './reqCreateQuotationSupplierType';
|
||||
export * from './reqCreateQuotationVersionId';
|
||||
export * from './reqCreateSupplier';
|
||||
export * from './reqCreateSupplierCode';
|
||||
@ -134,6 +145,7 @@ export * from './reqUpdateCardName';
|
||||
export * from './reqUpdateCardNumber';
|
||||
export * from './reqUpdateCardScript';
|
||||
export * from './reqUpdateCardStatus';
|
||||
export * from './reqUpdateCardUsageType';
|
||||
export * from './reqUpdateItem';
|
||||
export * from './reqUpdateItemCategory';
|
||||
export * from './reqUpdateItemCategoryType';
|
||||
@ -141,6 +153,7 @@ export * from './reqUpdateItemCode';
|
||||
export * from './reqUpdateItemDeliveryFeeYn';
|
||||
export * from './reqUpdateItemDeliveryType';
|
||||
export * from './reqUpdateItemImageUrl';
|
||||
export * from './reqUpdateItemInternetLowestPrice';
|
||||
export * from './reqUpdateItemInternetLowestPriceYn';
|
||||
export * from './reqUpdateItemLeadTime';
|
||||
export * from './reqUpdateItemMadeIn';
|
||||
@ -149,12 +162,15 @@ export * from './reqUpdateItemModelName';
|
||||
export * from './reqUpdateItemMoq';
|
||||
export * from './reqUpdateItemName';
|
||||
export * from './reqUpdateItemPrice';
|
||||
export * from './reqUpdateItemPurchasePrice';
|
||||
export * from './reqUpdateItemQuantityUnit';
|
||||
export * from './reqUpdateItemSellingPrice';
|
||||
export * from './reqUpdateItemSpec';
|
||||
export * from './reqUpdateItemVatYn';
|
||||
export * from './reqUpdateQuotationSetting';
|
||||
export * from './reqUpdateQuotationSettingAnchoringValue';
|
||||
export * from './reqUpdateQuotationSettingCardCount';
|
||||
export * from './reqUpdateQuotationSettingInternetAverageFee';
|
||||
export * from './reqUpdateQuotationSettingTargetMarginRate';
|
||||
export * from './reqUpdateSupplier';
|
||||
export * from './reqUpdateSupplierCode';
|
||||
@ -197,6 +213,10 @@ export * from './resItemItem';
|
||||
export * from './resItemList';
|
||||
export * from './resItemListMsg';
|
||||
export * from './resItemMsg';
|
||||
export * from './resLastSupplierType';
|
||||
export * from './resLastSupplierTypeMsg';
|
||||
export * from './resLastSupplierTypeQtNumber';
|
||||
export * from './resLastSupplierTypeSupplierType';
|
||||
export * from './resLogin';
|
||||
export * from './resLoginMsg';
|
||||
export * from './resLowestPriceResult';
|
||||
@ -260,6 +280,7 @@ export * from './supplierDataManagerEmail';
|
||||
export * from './supplierDataManagerName';
|
||||
export * from './supplierDataPriority';
|
||||
export * from './supplierDataUpdatedAt';
|
||||
export * from './supplierType';
|
||||
export * from './userRole';
|
||||
export * from './validationError';
|
||||
export * from './validationErrorCtx';
|
||||
|
||||
@ -12,6 +12,9 @@ import type { ItemDataSpec } from './itemDataSpec';
|
||||
import type { ItemDataManufacturer } from './itemDataManufacturer';
|
||||
import type { ItemDataMadeIn } from './itemDataMadeIn';
|
||||
import type { ItemDataPrice } from './itemDataPrice';
|
||||
import type { ItemDataInternetLowestPrice } from './itemDataInternetLowestPrice';
|
||||
import type { ItemDataPurchasePrice } from './itemDataPurchasePrice';
|
||||
import type { ItemDataSellingPrice } from './itemDataSellingPrice';
|
||||
import type { ItemDataMoq } from './itemDataMoq';
|
||||
import type { ItemDataLeadTime } from './itemDataLeadTime';
|
||||
import type { ItemDataQuantityUnit } from './itemDataQuantityUnit';
|
||||
@ -36,6 +39,9 @@ export interface ItemData {
|
||||
made_in?: ItemDataMadeIn;
|
||||
price?: ItemDataPrice;
|
||||
internet_lowest_price_yn?: boolean;
|
||||
internet_lowest_price?: ItemDataInternetLowestPrice;
|
||||
purchase_price?: ItemDataPurchasePrice;
|
||||
selling_price?: ItemDataSellingPrice;
|
||||
moq?: ItemDataMoq;
|
||||
lead_time?: ItemDataLeadTime;
|
||||
quantity_unit?: ItemDataQuantityUnit;
|
||||
|
||||
@ -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 ItemDataInternetLowestPrice = number | 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 ItemDataPurchasePrice = number | 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 ItemDataSellingPrice = number | null;
|
||||
@ -10,6 +10,8 @@ import type { QuotationDataManagerName } from './quotationDataManagerName';
|
||||
import type { QuotationDataManagerEmail } from './quotationDataManagerEmail';
|
||||
import type { QuotationDataManagerContactNumber } from './quotationDataManagerContactNumber';
|
||||
import type { QuotationDataMemo } from './quotationDataMemo';
|
||||
import type { QuotationDataMdPrice } from './quotationDataMdPrice';
|
||||
import type { QuotationDataSupplierType } from './quotationDataSupplierType';
|
||||
import type { QuotationDataPreferredSpYn } from './quotationDataPreferredSpYn';
|
||||
import type { QuotationDataPreferredSpId } from './quotationDataPreferredSpId';
|
||||
import type { QuotationDataPreferredSpName } from './quotationDataPreferredSpName';
|
||||
@ -36,6 +38,8 @@ export interface QuotationData {
|
||||
manager_email?: QuotationDataManagerEmail;
|
||||
manager_contact_number?: QuotationDataManagerContactNumber;
|
||||
memo?: QuotationDataMemo;
|
||||
md_price?: QuotationDataMdPrice;
|
||||
supplier_type?: QuotationDataSupplierType;
|
||||
iteration?: number;
|
||||
preferred_sp_yn?: QuotationDataPreferredSpYn;
|
||||
preferred_sp_id?: QuotationDataPreferredSpId;
|
||||
|
||||
@ -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 QuotationDataMdPrice = number | 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 { SupplierType } from './supplierType';
|
||||
|
||||
export type QuotationDataSupplierType = SupplierType | null;
|
||||
@ -13,6 +13,7 @@ export interface QuotationSettingData {
|
||||
user_id?: QuotationSettingDataUserId;
|
||||
target_margin_rate: number;
|
||||
anchoring_value: number;
|
||||
internet_average_fee?: number;
|
||||
card_count: number;
|
||||
created_at?: QuotationSettingDataCreatedAt;
|
||||
updated_at?: QuotationSettingDataUpdatedAt;
|
||||
|
||||
@ -6,7 +6,9 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* quotations.type 코드값. 1=renego(재협상 1:1), 2=requote(재견적 1:N).
|
||||
* quotations.type 코드값. 신규/재 × 협상(1:1)/견적(1:N).
|
||||
1=재협상(1:1), 2=재견적(1:N), 3=신규협상(1:1), 4=신규견적(1:N).
|
||||
기존 데이터 보존 위해 재협상/재견적 코드(1·2)는 고정, 신규는 3·4로 추가.
|
||||
*/
|
||||
export type QuotationType = typeof QuotationType[keyof typeof QuotationType];
|
||||
|
||||
@ -15,4 +17,6 @@ export type QuotationType = typeof QuotationType[keyof typeof QuotationType];
|
||||
export const QuotationType = {
|
||||
RENEGO: 1,
|
||||
REQUOTE: 2,
|
||||
NEW_NEGO: 3,
|
||||
NEW_QUOTE: 4,
|
||||
} as const;
|
||||
|
||||
@ -17,6 +17,7 @@ export interface ReqCreateCard {
|
||||
number?: ReqCreateCardNumber;
|
||||
script?: ReqCreateCardScript;
|
||||
edit_script?: ReqCreateCardEditScript;
|
||||
usage_type?: number;
|
||||
status?: number;
|
||||
condition?: ReqCreateCardCondition;
|
||||
memo?: ReqCreateCardMemo;
|
||||
|
||||
@ -12,6 +12,9 @@ import type { ReqCreateItemSpec } from './reqCreateItemSpec';
|
||||
import type { ReqCreateItemManufacturer } from './reqCreateItemManufacturer';
|
||||
import type { ReqCreateItemMadeIn } from './reqCreateItemMadeIn';
|
||||
import type { ReqCreateItemPrice } from './reqCreateItemPrice';
|
||||
import type { ReqCreateItemInternetLowestPrice } from './reqCreateItemInternetLowestPrice';
|
||||
import type { ReqCreateItemPurchasePrice } from './reqCreateItemPurchasePrice';
|
||||
import type { ReqCreateItemSellingPrice } from './reqCreateItemSellingPrice';
|
||||
import type { ReqCreateItemMoq } from './reqCreateItemMoq';
|
||||
import type { ReqCreateItemLeadTime } from './reqCreateItemLeadTime';
|
||||
import type { ReqCreateItemQuantityUnit } from './reqCreateItemQuantityUnit';
|
||||
@ -31,6 +34,9 @@ export interface ReqCreateItem {
|
||||
made_in?: ReqCreateItemMadeIn;
|
||||
price?: ReqCreateItemPrice;
|
||||
internet_lowest_price_yn?: boolean;
|
||||
internet_lowest_price?: ReqCreateItemInternetLowestPrice;
|
||||
purchase_price?: ReqCreateItemPurchasePrice;
|
||||
selling_price?: ReqCreateItemSellingPrice;
|
||||
moq?: ReqCreateItemMoq;
|
||||
lead_time?: ReqCreateItemLeadTime;
|
||||
quantity_unit?: ReqCreateItemQuantityUnit;
|
||||
|
||||
@ -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 ReqCreateItemInternetLowestPrice = number | 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 ReqCreateItemPurchasePrice = number | 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 ReqCreateItemSellingPrice = number | null;
|
||||
@ -10,6 +10,8 @@ import type { ReqCreateQuotationManagerName } from './reqCreateQuotationManagerN
|
||||
import type { ReqCreateQuotationManagerEmail } from './reqCreateQuotationManagerEmail';
|
||||
import type { ReqCreateQuotationManagerContactNumber } from './reqCreateQuotationManagerContactNumber';
|
||||
import type { ReqCreateQuotationMemo } from './reqCreateQuotationMemo';
|
||||
import type { ReqCreateQuotationMdPrice } from './reqCreateQuotationMdPrice';
|
||||
import type { ReqCreateQuotationSupplierType } from './reqCreateQuotationSupplierType';
|
||||
|
||||
export interface ReqCreateQuotation {
|
||||
qt_setting_id: string;
|
||||
@ -24,6 +26,8 @@ export interface ReqCreateQuotation {
|
||||
manager_email?: ReqCreateQuotationManagerEmail;
|
||||
manager_contact_number?: ReqCreateQuotationManagerContactNumber;
|
||||
memo?: ReqCreateQuotationMemo;
|
||||
md_price?: ReqCreateQuotationMdPrice;
|
||||
supplier_type?: ReqCreateQuotationSupplierType;
|
||||
item_ids?: string[];
|
||||
supplier_ids?: string[];
|
||||
card_ids?: 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 ReqCreateQuotationMdPrice = number | null;
|
||||
@ -8,5 +8,6 @@
|
||||
export interface ReqCreateQuotationSetting {
|
||||
target_margin_rate: number;
|
||||
anchoring_value?: number;
|
||||
internet_average_fee?: number;
|
||||
card_count?: number;
|
||||
}
|
||||
|
||||
@ -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 ReqCreateQuotationSupplierType = number | null;
|
||||
@ -8,6 +8,7 @@ import type { ReqUpdateCardName } from './reqUpdateCardName';
|
||||
import type { ReqUpdateCardNumber } from './reqUpdateCardNumber';
|
||||
import type { ReqUpdateCardScript } from './reqUpdateCardScript';
|
||||
import type { ReqUpdateCardEditScript } from './reqUpdateCardEditScript';
|
||||
import type { ReqUpdateCardUsageType } from './reqUpdateCardUsageType';
|
||||
import type { ReqUpdateCardStatus } from './reqUpdateCardStatus';
|
||||
import type { ReqUpdateCardCondition } from './reqUpdateCardCondition';
|
||||
import type { ReqUpdateCardMemo } from './reqUpdateCardMemo';
|
||||
@ -17,6 +18,7 @@ export interface ReqUpdateCard {
|
||||
number?: ReqUpdateCardNumber;
|
||||
script?: ReqUpdateCardScript;
|
||||
edit_script?: ReqUpdateCardEditScript;
|
||||
usage_type?: ReqUpdateCardUsageType;
|
||||
status?: ReqUpdateCardStatus;
|
||||
condition?: ReqUpdateCardCondition;
|
||||
memo?: ReqUpdateCardMemo;
|
||||
|
||||
@ -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 ReqUpdateCardUsageType = number | null;
|
||||
@ -15,6 +15,9 @@ import type { ReqUpdateItemManufacturer } from './reqUpdateItemManufacturer';
|
||||
import type { ReqUpdateItemMadeIn } from './reqUpdateItemMadeIn';
|
||||
import type { ReqUpdateItemPrice } from './reqUpdateItemPrice';
|
||||
import type { ReqUpdateItemInternetLowestPriceYn } from './reqUpdateItemInternetLowestPriceYn';
|
||||
import type { ReqUpdateItemInternetLowestPrice } from './reqUpdateItemInternetLowestPrice';
|
||||
import type { ReqUpdateItemPurchasePrice } from './reqUpdateItemPurchasePrice';
|
||||
import type { ReqUpdateItemSellingPrice } from './reqUpdateItemSellingPrice';
|
||||
import type { ReqUpdateItemMoq } from './reqUpdateItemMoq';
|
||||
import type { ReqUpdateItemLeadTime } from './reqUpdateItemLeadTime';
|
||||
import type { ReqUpdateItemQuantityUnit } from './reqUpdateItemQuantityUnit';
|
||||
@ -34,6 +37,9 @@ export interface ReqUpdateItem {
|
||||
made_in?: ReqUpdateItemMadeIn;
|
||||
price?: ReqUpdateItemPrice;
|
||||
internet_lowest_price_yn?: ReqUpdateItemInternetLowestPriceYn;
|
||||
internet_lowest_price?: ReqUpdateItemInternetLowestPrice;
|
||||
purchase_price?: ReqUpdateItemPurchasePrice;
|
||||
selling_price?: ReqUpdateItemSellingPrice;
|
||||
moq?: ReqUpdateItemMoq;
|
||||
lead_time?: ReqUpdateItemLeadTime;
|
||||
quantity_unit?: ReqUpdateItemQuantityUnit;
|
||||
|
||||
@ -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 ReqUpdateItemInternetLowestPrice = number | 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 ReqUpdateItemPurchasePrice = number | 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 ReqUpdateItemSellingPrice = number | null;
|
||||
@ -6,10 +6,12 @@
|
||||
*/
|
||||
import type { ReqUpdateQuotationSettingTargetMarginRate } from './reqUpdateQuotationSettingTargetMarginRate';
|
||||
import type { ReqUpdateQuotationSettingAnchoringValue } from './reqUpdateQuotationSettingAnchoringValue';
|
||||
import type { ReqUpdateQuotationSettingInternetAverageFee } from './reqUpdateQuotationSettingInternetAverageFee';
|
||||
import type { ReqUpdateQuotationSettingCardCount } from './reqUpdateQuotationSettingCardCount';
|
||||
|
||||
export interface ReqUpdateQuotationSetting {
|
||||
target_margin_rate?: ReqUpdateQuotationSettingTargetMarginRate;
|
||||
anchoring_value?: ReqUpdateQuotationSettingAnchoringValue;
|
||||
internet_average_fee?: ReqUpdateQuotationSettingInternetAverageFee;
|
||||
card_count?: ReqUpdateQuotationSettingCardCount;
|
||||
}
|
||||
|
||||
@ -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 ReqUpdateQuotationSettingInternetAverageFee = number | null;
|
||||
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* 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 { ResLastSupplierTypeMsg } from './resLastSupplierTypeMsg';
|
||||
import type { ResLastSupplierTypeSupplierType } from './resLastSupplierTypeSupplierType';
|
||||
import type { ResLastSupplierTypeQtNumber } from './resLastSupplierTypeQtNumber';
|
||||
|
||||
export interface ResLastSupplierType {
|
||||
result?: ErrorInfo;
|
||||
msg?: ResLastSupplierTypeMsg;
|
||||
supplier_type?: ResLastSupplierTypeSupplierType;
|
||||
qt_number?: ResLastSupplierTypeQtNumber;
|
||||
}
|
||||
@ -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 ResLastSupplierTypeMsg = 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 ResLastSupplierTypeQtNumber = 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 { SupplierType } from './supplierType';
|
||||
|
||||
export type ResLastSupplierTypeSupplierType = SupplierType | null;
|
||||
20
negodata/front/src/api/generated/model/supplierType.ts
Normal file
20
negodata/front/src/api/generated/model/supplierType.ts
Normal file
@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* suppliers.type 코드값(KTC 앵커링 기준). 0=기타는 매칭 실패 폴백용 — UI 선택지엔 미노출.
|
||||
*/
|
||||
export type SupplierType = typeof SupplierType[keyof typeof SupplierType];
|
||||
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
||||
export const SupplierType = {
|
||||
DISTRIBUTION: 1,
|
||||
MANUFACTURE: 2,
|
||||
SOLE_AGENCY: 3,
|
||||
ETC: 0,
|
||||
} as const;
|
||||
@ -30,6 +30,7 @@ import type {
|
||||
ReqRegenerateQuotation,
|
||||
ResCreateQuotation,
|
||||
ResDeleteQuotation,
|
||||
ResLastSupplierType,
|
||||
ResQuotation,
|
||||
ResQuotationCards,
|
||||
ResQuotationList,
|
||||
@ -852,6 +853,98 @@ export const useDeleteQuotation = <TError = void | HTTPValidationError,
|
||||
return useMutation(mutationOptions, queryClient);
|
||||
}
|
||||
/**
|
||||
* @summary 협력사 직전 견적 유형
|
||||
*/
|
||||
export const getSupplierLastType = (
|
||||
supplierId: string,
|
||||
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
|
||||
) => {
|
||||
|
||||
|
||||
return customFetch<ResLastSupplierType>(
|
||||
{url: `/v1/quotation/supplier/${supplierId}/last-type`, method: 'GET', signal
|
||||
},
|
||||
options);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
export const getGetSupplierLastTypeQueryKey = (supplierId?: string,) => {
|
||||
return [
|
||||
`/v1/quotation/supplier/${supplierId}/last-type`
|
||||
] as const;
|
||||
}
|
||||
|
||||
|
||||
export const getGetSupplierLastTypeQueryOptions = <TData = Awaited<ReturnType<typeof getSupplierLastType>>, TError = void | HTTPValidationError>(supplierId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSupplierLastType>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||
) => {
|
||||
|
||||
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetSupplierLastTypeQueryKey(supplierId);
|
||||
|
||||
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getSupplierLastType>>> = ({ signal }) => getSupplierLastType(supplierId, requestOptions, signal);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return { queryKey, queryFn, enabled: !!(supplierId), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getSupplierLastType>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
}
|
||||
|
||||
export type GetSupplierLastTypeQueryResult = NonNullable<Awaited<ReturnType<typeof getSupplierLastType>>>
|
||||
export type GetSupplierLastTypeQueryError = void | HTTPValidationError
|
||||
|
||||
|
||||
export function useGetSupplierLastType<TData = Awaited<ReturnType<typeof getSupplierLastType>>, TError = void | HTTPValidationError>(
|
||||
supplierId: string, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSupplierLastType>>, TError, TData>> & Pick<
|
||||
DefinedInitialDataOptions<
|
||||
Awaited<ReturnType<typeof getSupplierLastType>>,
|
||||
TError,
|
||||
Awaited<ReturnType<typeof getSupplierLastType>>
|
||||
> , 'initialData'
|
||||
>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
export function useGetSupplierLastType<TData = Awaited<ReturnType<typeof getSupplierLastType>>, TError = void | HTTPValidationError>(
|
||||
supplierId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSupplierLastType>>, TError, TData>> & Pick<
|
||||
UndefinedInitialDataOptions<
|
||||
Awaited<ReturnType<typeof getSupplierLastType>>,
|
||||
TError,
|
||||
Awaited<ReturnType<typeof getSupplierLastType>>
|
||||
> , 'initialData'
|
||||
>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
export function useGetSupplierLastType<TData = Awaited<ReturnType<typeof getSupplierLastType>>, TError = void | HTTPValidationError>(
|
||||
supplierId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSupplierLastType>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
/**
|
||||
* @summary 협력사 직전 견적 유형
|
||||
*/
|
||||
|
||||
export function useGetSupplierLastType<TData = Awaited<ReturnType<typeof getSupplierLastType>>, TError = void | HTTPValidationError>(
|
||||
supplierId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getSupplierLastType>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
|
||||
|
||||
const queryOptions = getGetSupplierLastTypeQueryOptions(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 getQuotation = (
|
||||
|
||||
@ -9,11 +9,13 @@ import { Input } from '@/components/ui/input';
|
||||
import { Sheet } from '@/components/ui/sheet';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { type NegotiationCard, type CardTab, generateCardCode } from '../types';
|
||||
import { CardUsageType } from '@/api/generated/model';
|
||||
import type { CardInput } from '../hooks/useCards';
|
||||
import { CardScriptEditor, deserialize, serializeToText } from '../editor';
|
||||
|
||||
const schema = z.object({
|
||||
isWildcard: z.boolean(),
|
||||
usageType: z.number(),
|
||||
code: z.string().trim().min(1, '카드번호는 필수 기입 사항입니다.'),
|
||||
title: z.string().trim().min(1, '카드이름은 필수 기입 사항입니다.'),
|
||||
editorScript: z
|
||||
@ -45,6 +47,7 @@ function buildDefaults(
|
||||
if (mode === 'edit' && card) {
|
||||
return {
|
||||
isWildcard: card.isWildcard,
|
||||
usageType: card.usageType,
|
||||
code: card.code,
|
||||
title: card.title,
|
||||
// 저장된 Slate JSON 우선, 없으면 레거시 평문 script 를 변수 노드로 복원.
|
||||
@ -57,6 +60,7 @@ function buildDefaults(
|
||||
const wild = activeTab === 'WILD';
|
||||
return {
|
||||
isWildcard: wild,
|
||||
usageType: CardUsageType.COMMON, // 기본: 공통(신규·재 모두)
|
||||
code: generateCardCode(wild),
|
||||
title: '',
|
||||
editorScript: deserialize(), // 새 빈 값(공용 상수 mutate 방지)
|
||||
@ -100,6 +104,7 @@ export function CardFormSheet({
|
||||
editorScript: v.editorScript,
|
||||
status: v.status,
|
||||
isWildcard: v.isWildcard,
|
||||
usageType: v.usageType,
|
||||
triggerCondition: v.triggerCondition,
|
||||
memo: v.memo,
|
||||
};
|
||||
@ -220,6 +225,34 @@ export function CardFormSheet({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카드 용도(usage_type): 공통 / 신규전용 / 재전용 */}
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="small" className="font-semibold">카드 용도</Typography>
|
||||
<Controller
|
||||
control={control}
|
||||
name="usageType"
|
||||
render={({ field }) => (
|
||||
<Select value={String(field.value)} onValueChange={(v) => field.onChange(Number(v))}>
|
||||
<SelectTrigger id="form-card-usage-type" className="w-full">
|
||||
<SelectValue>
|
||||
{(value) =>
|
||||
value === String(CardUsageType.NEW)
|
||||
? '신규견적전용'
|
||||
: value === String(CardUsageType.REUSE)
|
||||
? '재견적전용'
|
||||
: '공통'}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={String(CardUsageType.COMMON)}>공통</SelectItem>
|
||||
<SelectItem value={String(CardUsageType.NEW)}>신규견적전용</SelectItem>
|
||||
<SelectItem value={String(CardUsageType.REUSE)}>재견적전용</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="small" className="font-semibold">카드이름</Typography>
|
||||
|
||||
@ -21,6 +21,7 @@ export type CardInput = {
|
||||
editorScript: Descendant[];
|
||||
status: 'ACTIVE' | 'INACTIVE';
|
||||
isWildcard: boolean;
|
||||
usageType: number; // usage_type(CardUsageType): 1=공통 2=신규견적전용 3=재견적전용
|
||||
triggerCondition?: string;
|
||||
memo?: string;
|
||||
};
|
||||
@ -36,6 +37,7 @@ function cardError(res: ResCard): string | null {
|
||||
function toReq(input: CardInput): ReqCreateCard {
|
||||
return {
|
||||
is_wildcard: input.isWildcard,
|
||||
usage_type: input.usageType,
|
||||
name: input.title,
|
||||
number: input.code,
|
||||
script: serializeToText(input.editorScript), // 평문 미리보기({변수} 토큰 포함)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { NegotiationCard } from '@/types';
|
||||
import type { CardData } from '@/api/generated/model/cardData';
|
||||
import { CardStatus } from '@/api/generated/model';
|
||||
import { CardStatus, CardUsageType } from '@/api/generated/model';
|
||||
|
||||
export type { NegotiationCard };
|
||||
|
||||
@ -17,6 +17,7 @@ export function mapCardData(c: CardData): NegotiationCard {
|
||||
return {
|
||||
id: c.nego_card_id,
|
||||
isWildcard: c.is_wildcard ?? false,
|
||||
usageType: c.usage_type ?? CardUsageType.COMMON,
|
||||
code: c.number ?? '',
|
||||
title: c.name ?? '',
|
||||
scriptPreview: c.script ?? '',
|
||||
|
||||
@ -33,6 +33,8 @@ const schema = z.object({
|
||||
vatYn: z.boolean(),
|
||||
deliveryFeeYn: z.boolean(),
|
||||
internetLowestPriceYn: z.boolean(),
|
||||
purchasePrice: z.number({ message: '숫자를 입력해 주십시오.' }).min(0, '매입가는 0 이상이어야 합니다.'),
|
||||
sellingPrice: z.number({ message: '숫자를 입력해 주십시오.' }).min(0, '판매가는 0 이상이어야 합니다.'),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
@ -71,6 +73,8 @@ function buildDefaults(mode: 'create' | 'edit', product: Product | null): FormVa
|
||||
vatYn: product.vat_yn !== false,
|
||||
deliveryFeeYn: product.delivery_fee_yn || false,
|
||||
internetLowestPriceYn: product.internet_lowest_price_yn || false,
|
||||
purchasePrice: product.purchase_price || 0,
|
||||
sellingPrice: product.selling_price || 0,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@ -91,6 +95,8 @@ function buildDefaults(mode: 'create' | 'edit', product: Product | null): FormVa
|
||||
vatYn: true,
|
||||
deliveryFeeYn: false,
|
||||
internetLowestPriceYn: true,
|
||||
purchasePrice: 0,
|
||||
sellingPrice: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@ -145,6 +151,8 @@ export function ProductFormSheet({
|
||||
vat_yn: v.vatYn,
|
||||
delivery_fee_yn: v.deliveryFeeYn,
|
||||
internet_lowest_price_yn: v.internetLowestPriceYn,
|
||||
purchase_price: v.purchasePrice,
|
||||
selling_price: v.sellingPrice,
|
||||
};
|
||||
|
||||
if (mode === 'create') {
|
||||
@ -262,6 +270,33 @@ export function ProductFormSheet({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* 매입가 */}
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="label">매입가 (₩)</Typography>
|
||||
<Input
|
||||
id="form-product-purchase-price"
|
||||
type="number"
|
||||
{...register('purchasePrice', { valueAsNumber: true })}
|
||||
className={`${inputClass} font-mono`}
|
||||
placeholder="0"
|
||||
/>
|
||||
{errors.purchasePrice && <p className="text-[10px] text-rose-500">{errors.purchasePrice.message}</p>}
|
||||
</div>
|
||||
{/* 판매가 */}
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="label">판매가 (₩)</Typography>
|
||||
<Input
|
||||
id="form-product-selling-price"
|
||||
type="number"
|
||||
{...register('sellingPrice', { valueAsNumber: true })}
|
||||
className={`${inputClass} font-mono`}
|
||||
placeholder="0"
|
||||
/>
|
||||
{errors.sellingPrice && <p className="text-[10px] text-rose-500">{errors.sellingPrice.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Model Name */}
|
||||
<div className="space-y-1">
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, PlusSquare, ArrowRight, Loader2 } from 'lucide-react';
|
||||
import { useGetSupplierLastType } from '@/api/generated/quotation/quotation';
|
||||
import { updateItem } from '@/api/generated/item/item';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@ -7,7 +9,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import type { Product, Partner, QuotationSetting, NegotiationCard } from '../types';
|
||||
import type { CreateQuotationInput } from '../hooks/useQuotations';
|
||||
import { QuotationType } from '@/api/generated/model';
|
||||
import { QUOTATION_TYPE_OPTIONS } from '../types';
|
||||
import { QUOTATION_TYPE_OPTIONS, supplierTypeOptions } from '../types';
|
||||
import { showToast } from '@/lib/notify';
|
||||
|
||||
// datetime-local 디폴트값: 현재 한국시간(Asia/Seoul)의 'YYYY-MM-DDTHH:mm'.
|
||||
// sv-SE 로케일이 'YYYY-MM-DD HH:mm:ss' 를 주고, timeZone 명시로 브라우저 TZ 와 무관하게 KST 로 고정한다.
|
||||
@ -44,9 +47,37 @@ export function QuotationCreateModal({
|
||||
const [settingId, setSettingId] = useState(quotationSettings[0]?.qt_setting_id ?? '');
|
||||
const [selectedCardIds, setSelectedCardIds] = useState<string[]>([]);
|
||||
const [memo, setMemo] = useState('');
|
||||
const [mdPrice, setMdPrice] = useState(''); // MD 제시가(원). 비우면 미전송 → 서버가 마진식으로 목표가 산정
|
||||
const [purchaseInput, setPurchaseInput] = useState(''); // 재견적·재협상 매입가(상품 저장값 디폴트, 필수)
|
||||
const [sellingInput, setSellingInput] = useState(''); // 재견적·재협상 판매가(상품 저장값 디폴트, 선택)
|
||||
const [supplierType, setSupplierType] = useState(''); // 협력사 유형(SupplierType). 재견적 1:1 → 견적에 기록
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const typeOptions = QUOTATION_TYPE_OPTIONS;
|
||||
|
||||
// 협력사 유형은 재협상(1:1)에서만. 선택된 협력사의 직전 견적 supplier_type 을 조회해 디폴트로 채운다.
|
||||
const renegoSupplierId = type === QuotationType.RENEGO ? (selectedPartnerIds[0] ?? '') : '';
|
||||
const lastTypeQuery = useGetSupplierLastType(renegoSupplierId, {
|
||||
query: { enabled: !!renegoSupplierId },
|
||||
});
|
||||
const prevSupplierType = lastTypeQuery.data?.supplier_type ?? null; // 협력사 직전 견적 유형(없으면 null)
|
||||
const prevQtNumber = lastTypeQuery.data?.qt_number ?? '';
|
||||
// 협력사가 정해지면 직전 견적 유형으로 디폴트(이후 사용자가 바꾸면 그 값 유지).
|
||||
useEffect(() => {
|
||||
setSupplierType(prevSupplierType != null ? String(prevSupplierType) : '');
|
||||
}, [renegoSupplierId, prevSupplierType]);
|
||||
|
||||
// 재견적·재협상(RE)에서만 매입가/판매가 입력을 노출한다. 상품이 정해지면 그 상품 저장값으로 디폴트.
|
||||
const selectedProduct = products.find((p) => p.id === productId);
|
||||
const isReType = type === QuotationType.RENEGO || type === QuotationType.REQUOTE;
|
||||
const showPrices = isReType && !!productId;
|
||||
// 상품/유형이 바뀌면 그 상품의 저장된 매입가·판매가로 입력칸을 채운다(이후 사용자가 고치면 유지).
|
||||
useEffect(() => {
|
||||
if (!isReType || !selectedProduct) return;
|
||||
setPurchaseInput(selectedProduct.purchase_price != null ? String(selectedProduct.purchase_price) : '');
|
||||
setSellingInput(selectedProduct.selling_price != null ? String(selectedProduct.selling_price) : '');
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [productId, isReType]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const togglePartner = (id: string) =>
|
||||
@ -64,6 +95,17 @@ export function QuotationCreateModal({
|
||||
if (submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
// 재견적·재협상은 매입가 필수 — 입력된 매입가/판매가를 상품에 저장한 뒤 진행한다.
|
||||
if (showPrices) {
|
||||
if (!purchaseInput) {
|
||||
showToast('재견적·재협상은 매입가가 필수입니다.', 'error');
|
||||
return; // finally 에서 submitting 해제
|
||||
}
|
||||
await updateItem(productId, {
|
||||
purchase_price: Number(purchaseInput),
|
||||
selling_price: sellingInput ? Number(sellingInput) : undefined,
|
||||
});
|
||||
}
|
||||
// 서버가 견적+세션 생성을 끝내고 응답할 때까지 기다린 뒤에 완료(닫기) 처리한다.
|
||||
const ok = await onCreate({
|
||||
title,
|
||||
@ -74,6 +116,8 @@ export function QuotationCreateModal({
|
||||
settingId,
|
||||
cardIds: selectedCardIds,
|
||||
memo,
|
||||
mdPrice: mdPrice ? Number(mdPrice) : null,
|
||||
supplierType: supplierType ? Number(supplierType) : null,
|
||||
});
|
||||
if (ok) onClose();
|
||||
} finally {
|
||||
@ -92,7 +136,7 @@ export function QuotationCreateModal({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full max-w-xl bg-card border border-border rounded-lg shadow-2xl p-6 overflow-hidden animate-scale-up font-mono">
|
||||
<div className="w-full max-w-2xl bg-card border border-border rounded-lg shadow-2xl p-6 overflow-hidden animate-scale-up font-mono">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between pb-4 border-b border-border">
|
||||
@ -107,11 +151,11 @@ export function QuotationCreateModal({
|
||||
|
||||
{/* Steps indicator */}
|
||||
<div className="flex items-center justify-between gap-2 py-4 border-b border-border/40 text-[10px] text-muted-foreground">
|
||||
<Typography as="span" variant="label" className={`font-semibold ${step >= 1 ? 'text-primary' : 'text-muted-foreground'}`}>1. 기본 등록</Typography>
|
||||
<Typography as="span" variant="label" className={`font-semibold ${step >= 1 ? 'text-primary' : 'text-muted-foreground'}`}>1. 기본 정보</Typography>
|
||||
<ArrowRight size={10} />
|
||||
<Typography as="span" variant="label" className={`font-semibold ${step >= 2 ? 'text-primary' : 'text-muted-foreground'}`}>2. 협력사 선택</Typography>
|
||||
<Typography as="span" variant="label" className={`font-semibold ${step >= 2 ? 'text-primary' : 'text-muted-foreground'}`}>2. 협력사 초청</Typography>
|
||||
<ArrowRight size={10} />
|
||||
<Typography as="span" variant="label" className={`font-semibold ${step >= 3 ? 'text-primary' : 'text-muted-foreground'}`}>3. 설정 및 완료</Typography>
|
||||
<Typography as="span" variant="label" className={`font-semibold ${step >= 3 ? 'text-primary' : 'text-muted-foreground'}`}>3. 설정·완료</Typography>
|
||||
</div>
|
||||
|
||||
{/* Step content */}
|
||||
@ -119,21 +163,10 @@ export function QuotationCreateModal({
|
||||
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="label">견적건명</Typography>
|
||||
<Input
|
||||
id="wizard-title"
|
||||
type="text"
|
||||
className="text-xs"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="예: 6월 배터리 원부자재 견적 의뢰"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* 견적 유형이 맨 위 — 신규/재 여부가 아래 매입가 필수 여부까지 결정한다 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="label">유형</Typography>
|
||||
<Typography as="label" variant="label">견적 유형</Typography>
|
||||
<Select
|
||||
value={String(type)}
|
||||
onValueChange={(v) => {
|
||||
@ -167,6 +200,18 @@ export function QuotationCreateModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="label">견적건명</Typography>
|
||||
<Input
|
||||
id="wizard-title"
|
||||
type="text"
|
||||
className="text-xs"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="예: 6월 배터리 원부자재 견적 의뢰"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="label">상품</Typography>
|
||||
<Select value={productId} onValueChange={setProductId}>
|
||||
@ -189,6 +234,50 @@ export function QuotationCreateModal({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* MD 제시가 — 신규·재 공통(입력 시 목표가로 사용) */}
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="label">MD 제시가 (선택)</Typography>
|
||||
<Input
|
||||
id="wizard-md-price"
|
||||
type="number"
|
||||
min={0}
|
||||
className="text-xs"
|
||||
value={mdPrice}
|
||||
onChange={(e) => setMdPrice(e.target.value)}
|
||||
placeholder="입력 시 목표가로 사용 · 미입력 시 자동 산정"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 재견적·재협상 — 매입가(필수)·판매가, 선택 상품의 저장값으로 디폴트 */}
|
||||
{showPrices && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="label" className="text-amber-600">매입가 (필수)</Typography>
|
||||
<Input
|
||||
id="wizard-purchase-price"
|
||||
type="number"
|
||||
min={0}
|
||||
className="text-xs"
|
||||
value={purchaseInput}
|
||||
onChange={(e) => setPurchaseInput(e.target.value)}
|
||||
placeholder="상품 저장값 · 비우면 진행 불가"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="label">판매가 (선택)</Typography>
|
||||
<Input
|
||||
id="wizard-selling-price"
|
||||
type="number"
|
||||
min={0}
|
||||
className="text-xs"
|
||||
value={sellingInput}
|
||||
onChange={(e) => setSellingInput(e.target.value)}
|
||||
placeholder="상품 저장값 · 마진 상한 산정에 사용"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -226,6 +315,34 @@ export function QuotationCreateModal({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 협력사 유형 — 재협상(1:1)에서 협력사 선택 후 노출. 직전 견적 값 자동 디폴트. */}
|
||||
{type === QuotationType.RENEGO && selectedPartnerIds.length > 0 && (
|
||||
<div className="space-y-1 pt-3 border-t border-border/40">
|
||||
<Typography as="label" variant="label">협력사 유형</Typography>
|
||||
<Select value={supplierType} onValueChange={(v) => setSupplierType(v ?? '')}>
|
||||
<SelectTrigger id="wizard-supplier-type" className="w-full">
|
||||
<SelectValue>
|
||||
{(value) =>
|
||||
value
|
||||
? supplierTypeOptions.find((o) => String(o.value) === value)?.label ?? ''
|
||||
: '협력사 유형 선택...'
|
||||
}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{supplierTypeOptions.map((o) => (
|
||||
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{prevSupplierType != null && (
|
||||
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground">
|
||||
이전 견적({prevQtNumber}) 값으로 자동 선택됨 · 수정 가능
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -317,7 +434,17 @@ export function QuotationCreateModal({
|
||||
|
||||
<div className="flex gap-2">
|
||||
{step < 3 ? (
|
||||
<Button type="button" size="sm" onClick={() => setStep((prev) => prev + 1)}>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (step === 1 && showPrices && !purchaseInput) {
|
||||
showToast('재견적·재협상은 매입가가 필수입니다.', 'error');
|
||||
return;
|
||||
}
|
||||
setStep((prev) => prev + 1);
|
||||
}}
|
||||
>
|
||||
다음 단계로
|
||||
</Button>
|
||||
) : (
|
||||
|
||||
@ -24,16 +24,18 @@ export function QuotationSettingsModal({
|
||||
}: QuotationSettingsModalProps) {
|
||||
const [targetMargin, setTargetMargin] = useState('');
|
||||
const [anchoringValue, setAnchoringValue] = useState('');
|
||||
const [internetFee, setInternetFee] = useState('7.8');
|
||||
const [cardUseCount, setCardUseCount] = useState('');
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleAdd = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const ok = onAdd({ targetMargin, anchoringValue, cardUseCount });
|
||||
const ok = onAdd({ targetMargin, anchoringValue, internetFee, cardUseCount });
|
||||
if (ok) {
|
||||
setTargetMargin('');
|
||||
setAnchoringValue('');
|
||||
setInternetFee('7.8');
|
||||
setCardUseCount('');
|
||||
}
|
||||
};
|
||||
@ -99,7 +101,7 @@ export function QuotationSettingsModal({
|
||||
<form onSubmit={handleAdd} className="space-y-3 pt-4 border-t border-border/60">
|
||||
<span className="font-bold text-foreground block text-xs">신규 견적 세팅 추가</span>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="muted" className="text-[10px] font-semibold">목표 마진율 (%)</Typography>
|
||||
<Input type="number" step="0.1" value={targetMargin} onChange={(e) => setTargetMargin(e.target.value)} placeholder="예: 12" />
|
||||
@ -108,6 +110,10 @@ export function QuotationSettingsModal({
|
||||
<Typography as="label" variant="muted" className="text-[10px] font-semibold">앵커링 값</Typography>
|
||||
<Input type="number" step="0.01" value={anchoringValue} onChange={(e) => setAnchoringValue(e.target.value)} placeholder="예: 0.01" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="muted" className="text-[10px] font-semibold">인터넷 수수료율 (%)</Typography>
|
||||
<Input type="number" step="0.1" value={internetFee} onChange={(e) => setInternetFee(e.target.value)} placeholder="예: 7.8" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Typography as="label" variant="muted" className="text-[10px] font-semibold">카드 사용 횟수</Typography>
|
||||
<Input type="number" step="1" value={cardUseCount} onChange={(e) => setCardUseCount(e.target.value)} placeholder="예: 3" />
|
||||
|
||||
@ -36,11 +36,14 @@ export type CreateQuotationInput = {
|
||||
settingId: string;
|
||||
cardIds: string[];
|
||||
memo: string;
|
||||
mdPrice?: number | null; // MD 제시가(원). 비우면 미전송 → 서버가 기존 마진식으로 목표가 산정
|
||||
supplierType?: number | null; // 협력사 유형(SupplierType). 재견적 1:1 → 견적에 기록
|
||||
};
|
||||
|
||||
export type SettingInput = {
|
||||
targetMargin: string;
|
||||
anchoringValue: string;
|
||||
internetFee: string;
|
||||
cardUseCount: string;
|
||||
};
|
||||
|
||||
@ -116,13 +119,14 @@ export function useQuotations(params: ListQuotationsParams) {
|
||||
const addSetting = (input: SettingInput): boolean => {
|
||||
const marginPct = Number(String(input.targetMargin).replace('%', '').trim());
|
||||
const anchoring = Number(String(input.anchoringValue).trim());
|
||||
const feePct = Number(String(input.internetFee).replace('%', '').trim());
|
||||
const cardCount = parseInt(String(input.cardUseCount).replace(/[^0-9-]/g, ''), 10);
|
||||
if (!Number.isFinite(marginPct) || !Number.isFinite(anchoring) || !Number.isInteger(cardCount)) {
|
||||
showToast('목표 마진율·앵커링 값·카드 사용 횟수를 숫자로 입력해야 합니다.', 'error');
|
||||
if (!Number.isFinite(marginPct) || !Number.isFinite(anchoring) || !Number.isFinite(feePct) || !Number.isInteger(cardCount)) {
|
||||
showToast('목표 마진율·앵커링 값·수수료율·카드 사용 횟수를 숫자로 입력해야 합니다.', 'error');
|
||||
return false;
|
||||
}
|
||||
createSettingMutation.mutate(
|
||||
{ data: { target_margin_rate: marginPct / 100, anchoring_value: anchoring, card_count: cardCount } },
|
||||
{ data: { target_margin_rate: marginPct / 100, anchoring_value: anchoring, internet_average_fee: feePct / 100, card_count: cardCount } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
invalidateSettings();
|
||||
@ -183,6 +187,8 @@ export function useQuotations(params: ListQuotationsParams) {
|
||||
manager_email: me?.email || undefined,
|
||||
manager_contact_number: me?.contact || undefined,
|
||||
memo: input.memo.trim() || undefined,
|
||||
md_price: input.mdPrice && input.mdPrice > 0 ? input.mdPrice : undefined,
|
||||
supplier_type: input.supplierType ?? undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
@ -4,7 +4,7 @@ import type { QuotationSettingData } from '@/api/generated/model/quotationSettin
|
||||
import type { QuotationData } from '@/api/generated/model/quotationData';
|
||||
import type { SessionData } from '@/api/generated/model/sessionData';
|
||||
import type { QuotationCardData } from '@/api/generated/model/quotationCardData';
|
||||
import { QuotationType, QuotationStatus, SessionStatus, CardType } from '@/api/generated/model';
|
||||
import { QuotationType, QuotationStatus, SessionStatus, CardType, SupplierType } from '@/api/generated/model';
|
||||
import { DELIVERY_TYPE_LABEL } from '@/lib/enumLabels';
|
||||
import { toMinPrice } from '@/features/products/types';
|
||||
import type { Product, Partner, NegotiationCard } from '@/types';
|
||||
@ -136,14 +136,29 @@ export const QUOTATION_STATUS_OPTIONS = Object.values(QuotationStatus).map((valu
|
||||
export const QUOTATION_TYPE_LABEL: Record<QuotationType, string> = {
|
||||
[QuotationType.RENEGO]: '재협상',
|
||||
[QuotationType.REQUOTE]: '재견적',
|
||||
[QuotationType.NEW_NEGO]: '신규협상',
|
||||
[QuotationType.NEW_QUOTE]: '신규견적',
|
||||
};
|
||||
export const quotationTypeLabel = (t?: number | null): string =>
|
||||
t != null ? QUOTATION_TYPE_LABEL[t as QuotationType] ?? String(t) : '';
|
||||
export const QUOTATION_TYPE_OPTIONS = [QuotationType.REQUOTE, QuotationType.RENEGO].map((value) => ({
|
||||
export const QUOTATION_TYPE_OPTIONS = [
|
||||
QuotationType.NEW_QUOTE,
|
||||
QuotationType.NEW_NEGO,
|
||||
QuotationType.REQUOTE,
|
||||
QuotationType.RENEGO,
|
||||
].map((value) => ({
|
||||
value,
|
||||
label: QUOTATION_TYPE_LABEL[value],
|
||||
}));
|
||||
|
||||
// 협력사 유형 선택지(견적생성 모달). 재견적은 1:1이라 견적에 협력사 유형을 박는다. 없음(ETC=0) 포함.
|
||||
export const supplierTypeOptions: { value: number; label: string }[] = [
|
||||
{ value: SupplierType.DISTRIBUTION, label: '유통' },
|
||||
{ value: SupplierType.MANUFACTURE, label: '제조' },
|
||||
{ value: SupplierType.SOLE_AGENCY, label: '총판' },
|
||||
{ value: SupplierType.ETC, label: '없음' },
|
||||
];
|
||||
|
||||
// ── 라운드 체인(같은 견적번호) ───────────────────────────────────────────
|
||||
// 한 라운드(견적)의 결과를 한 단어로. 낙찰=종료, 동가/마감=후속 라운드 가능, 진행중=아직 안 닫힘.
|
||||
export type ChainRoundState = 'awarded' | 'equal' | 'closed' | 'active';
|
||||
|
||||
@ -23,6 +23,7 @@ export type Partner = SupplierData & {
|
||||
export interface NegotiationCard {
|
||||
id: string;
|
||||
isWildcard: boolean;
|
||||
usageType: number; // usage_type(CardUsageType): 1=공통 2=신규견적전용 3=재견적전용
|
||||
code: string;
|
||||
title: string;
|
||||
scriptPreview: string;
|
||||
|
||||
@ -156,6 +156,9 @@ CREATE TABLE IF NOT EXISTS partner.items (
|
||||
vat_yn BOOLEAN NULL, -- 부가세 포함 여부
|
||||
delivery_fee_yn BOOLEAN NULL, -- 배송비 포함 여부
|
||||
internet_lowest_price_yn BOOLEAN NOT NULL DEFAULT FALSE, -- 최저가 솔루션의 원자성을 보존하기 위한 보조 컬럼
|
||||
internet_lowest_price BIGINT NULL, -- 인터넷 최저가
|
||||
purchase_price BIGINT NULL, -- 매입가
|
||||
selling_price BIGINT NULL, -- 판매가
|
||||
category_type INTEGER NOT NULL DEFAULT 1, -- 자동으로 늘어나는 숫자 ( 카테고리 찾을때 유용한 컬럼)
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
|
||||
@ -197,6 +200,7 @@ CREATE TABLE IF NOT EXISTS card.nego_cards (
|
||||
number VARCHAR(10) NULL, -- 식별번호
|
||||
script VARCHAR(255) NULL, -- 협상 스크립트
|
||||
edit_script JSONB NULL, -- 편집된 스크립트(JSON)
|
||||
usage_type SMALLINT NOT NULL DEFAULT 1, -- 카드 적용 견적 구분(CardUsageType): 1=공통 2=신규견적전용 3=재견적전용
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
|
||||
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
|
||||
@ -209,6 +213,7 @@ CREATE TABLE IF NOT EXISTS card.wild_cards (
|
||||
number VARCHAR(10) NULL, -- 식별번호
|
||||
script VARCHAR(255) NULL, -- 협상 스크립트
|
||||
edit_script JSONB NULL, -- 편집된 스크립트(JSON)
|
||||
usage_type SMALLINT NOT NULL DEFAULT 1, -- 카드 적용 견적 구분(CardUsageType): 1=공통 2=신규견적전용 3=재견적전용
|
||||
condition VARCHAR(255) NULL, -- 커스터마이징 협상 카드이기 때문에 상세 조건을 기재해야 함
|
||||
available BOOLEAN NOT NULL DEFAULT FALSE, -- 와일드 카드는 수동으로 코드에 추가해야 하기 때문에 컬럼 추가
|
||||
memo VARCHAR(255) NULL, -- 사용 조건 이외에 자유롭게 적을 수 있는 메모
|
||||
@ -243,6 +248,7 @@ CREATE TABLE IF NOT EXISTS quotation.quotation_settings (
|
||||
user_id uuid NOT NULL, -- 견적 설정을 생성한 유저 아이디(company.users.user_id)
|
||||
target_margin_rate NUMERIC(8,6) NOT NULL, -- 목표 마진율 (정수부 2자리 + 소수 6자리, -99.999999~99.999999)
|
||||
anchoring_value NUMERIC(8,6) NOT NULL DEFAULT 0.01, -- 앵커링 값 (정수부 2자리 + 소수 6자리)
|
||||
internet_average_fee NUMERIC(8,6) NOT NULL DEFAULT 0.078, -- 인터넷 평균 수수료율
|
||||
card_count INTEGER NOT NULL DEFAULT 3, -- 한개의 협상 안에서 협상카드 사용 횟수
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
|
||||
@ -265,6 +271,8 @@ CREATE TABLE IF NOT EXISTS quotation.quotations (
|
||||
manager_email VARCHAR(255) NULL, -- 담당자 이메일
|
||||
manager_contact_number VARCHAR(20) NULL, -- 담당자 연락처
|
||||
memo VARCHAR(100) NULL, -- 메모
|
||||
md_price BIGINT NULL, -- MD 제시가(원). 목표가 산정 최우선값 (견적생성 모달 입력)
|
||||
supplier_type SMALLINT NULL, -- 협력사 유형(SupplierType). 재견적 1:1 → 견적에 기록 (견적생성 모달 입력)
|
||||
iteration INTEGER NOT NULL DEFAULT 0, -- 반복 횟수
|
||||
preferred_sp_yn BOOLEAN NULL, -- 선호 공급사 지정 여부
|
||||
preferred_sp_id uuid NULL, -- 선호 공급사(partner.suppliers.supplier_id)
|
||||
@ -288,6 +296,7 @@ CREATE TABLE IF NOT EXISTS negotiation.sessions (
|
||||
qt_round INTEGER NOT NULL, -- 견적 라운드(스냅샷)
|
||||
qt_type SMALLINT NOT NULL, -- 견적 유형(스냅샷): 1=renego, 2=requote
|
||||
target_price BIGINT NOT NULL, -- 목표가(원)
|
||||
target_anchoring_price BIGINT NULL, -- 앵커링가(원)
|
||||
status SMALLINT NOT NULL, -- 진행 상태 (코드, 앱 enum 매핑)
|
||||
bid_price BIGINT NULL, -- 입찰가(원)
|
||||
bid_at TIMESTAMPTZ NULL, -- 입찰 시각
|
||||
|
||||
27
postgres-init/04-alter-negodata-pricing.sql
Normal file
27
postgres-init/04-alter-negodata-pricing.sql
Normal file
@ -0,0 +1,27 @@
|
||||
-- negosium 견적 개편: 가격(매입/판매)·수수료율·앵커링가 + 견적/카드/협력사 분류 컬럼
|
||||
-- 적용 대상: 이미 돌아가는 DB (신규/리셋 DB는 postgres-init/01-schema.sql 에 이미 포함).
|
||||
|
||||
-- 상품: 인터넷최저가 실값 + 매입가 + 판매가
|
||||
ALTER TABLE partner.items
|
||||
ADD COLUMN IF NOT EXISTS internet_lowest_price BIGINT,
|
||||
ADD COLUMN IF NOT EXISTS purchase_price BIGINT,
|
||||
ADD COLUMN IF NOT EXISTS selling_price BIGINT;
|
||||
|
||||
-- 견적 설정: 인터넷 평균 수수료율
|
||||
ALTER TABLE quotation.quotation_settings
|
||||
ADD COLUMN IF NOT EXISTS internet_average_fee NUMERIC(8,6) NOT NULL DEFAULT 0.078;
|
||||
|
||||
-- 세션: 앵커링가
|
||||
ALTER TABLE negotiation.sessions
|
||||
ADD COLUMN IF NOT EXISTS target_anchoring_price BIGINT;
|
||||
|
||||
-- 견적: MD 제시가 + 협력사(공급채널) 유형
|
||||
ALTER TABLE quotation.quotations
|
||||
ADD COLUMN IF NOT EXISTS md_price BIGINT,
|
||||
ADD COLUMN IF NOT EXISTS supplier_type SMALLINT;
|
||||
|
||||
-- 카드: 사용 범위 구분(CardUsageType): 1=공통 2=신규견적전용 3=재견적전용
|
||||
ALTER TABLE card.nego_cards
|
||||
ADD COLUMN IF NOT EXISTS usage_type SMALLINT NOT NULL DEFAULT 1;
|
||||
ALTER TABLE card.wild_cards
|
||||
ADD COLUMN IF NOT EXISTS usage_type SMALLINT NOT NULL DEFAULT 1;
|
||||
Loading…
Reference in New Issue
Block a user