[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:
Mina Choi 2026-06-26 17:09:14 +09:00
parent 21e9f71f53
commit 103c80046e
59 changed files with 871 additions and 78 deletions

View File

@ -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, 앱에서 갱신)

View File

@ -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):

View File

@ -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) # 입찰 시각

View File

@ -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 # 재견적전용

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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 # 그 견적의 번호(이전 견적 값임을 표시용)

View File

@ -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)):

View File

@ -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

View File

@ -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}

View File

@ -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 = []

View File

@ -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(

View File

@ -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;

View 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;

View File

@ -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';

View File

@ -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;

View File

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

View File

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

View File

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

View File

@ -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;

View File

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

View File

@ -0,0 +1,9 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { SupplierType } from './supplierType';
export type QuotationDataSupplierType = SupplierType | null;

View File

@ -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;

View File

@ -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;

View File

@ -17,6 +17,7 @@ export interface ReqCreateCard {
number?: ReqCreateCardNumber;
script?: ReqCreateCardScript;
edit_script?: ReqCreateCardEditScript;
usage_type?: number;
status?: number;
condition?: ReqCreateCardCondition;
memo?: ReqCreateCardMemo;

View File

@ -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;

View File

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

View File

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

View File

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

View File

@ -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[];

View File

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

View File

@ -8,5 +8,6 @@
export interface ReqCreateQuotationSetting {
target_margin_rate: number;
anchoring_value?: number;
internet_average_fee?: number;
card_count?: number;
}

View File

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

View File

@ -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;

View File

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

View File

@ -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;

View File

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

View File

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

View File

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

View File

@ -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;
}

View File

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

View File

@ -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;
}

View File

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

View File

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

View File

@ -0,0 +1,9 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { SupplierType } from './supplierType';
export type ResLastSupplierTypeSupplierType = SupplierType | null;

View 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;

View File

@ -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 = (

View File

@ -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>

View File

@ -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), // 평문 미리보기({변수} 토큰 포함)

View File

@ -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 ?? '',

View File

@ -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">

View File

@ -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>
) : (

View File

@ -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" />

View File

@ -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 {

View File

@ -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';

View File

@ -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;

View File

@ -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, -- 입찰 시각

View 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;