[refactor] negodata: 목표가 산정내역을 백엔드 /target-breakdown 엔드포인트로 이관
- 생성(_calc_target_price)과 표시(get_target_breakdown)가 _candidates 헬퍼를 공유 → 프론트 재계산 제거, 저장된 목표가와 항상 일치 - TargetPriceModal: useGetTargetBreakdown 으로 후보·채택·앵커링가 '표시만' - QuotationType.is_new() 한 곳에서 신규유형 판단(생성·산정 공통) - enum 라벨 단일출처(lib/enumLabels.ts): 협력사유형/카드 사용구분/유저상태 members·CardFormSheet 는 거기서 re-export Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
019f3dbeac
commit
33ce82997a
@ -138,6 +138,11 @@ class QuotationType(CodeEnum):
|
||||
NEW_NEGO = 3 # 신규협상(1:1)
|
||||
NEW_QUOTE = 4 # 신규견적(1:N)
|
||||
|
||||
@classmethod
|
||||
def is_new(cls, code) -> bool:
|
||||
"""신규(NEW_*) 견적유형이면 True. 목표가 후보(신규=인터넷최저가만)가 이 분기에 의존하므로 한 곳에서만 판단한다."""
|
||||
return code in (cls.NEW_NEGO.value, cls.NEW_QUOTE.value)
|
||||
|
||||
|
||||
class QuotationStatus(CodeEnum):
|
||||
"""quotations.status 코드값(SMALLINT). 프론트 견적상태 뱃지와 매핑된다."""
|
||||
|
||||
@ -180,3 +180,25 @@ class Res_QuotationCards(Res_WebPacketProtocol):
|
||||
class Res_LastSupplierType(Res_WebPacketProtocol):
|
||||
supplier_type: Optional[SupplierType] = None # 협력사 직전 견적의 유형(없으면 None)
|
||||
qt_number: Optional[str] = None # 그 견적의 번호(이전 견적 값임을 표시용)
|
||||
|
||||
|
||||
class TargetCandidate(WebPacketProtocol):
|
||||
basis: str
|
||||
label: str
|
||||
value: int
|
||||
|
||||
|
||||
class Res_TargetBreakdown(Res_WebPacketProtocol):
|
||||
is_new: bool = False
|
||||
is_inherited: bool = False
|
||||
md_price: Optional[int] = None
|
||||
internet_lowest: Optional[int] = None
|
||||
purchase: Optional[int] = None
|
||||
selling: Optional[int] = None
|
||||
fee: float = 0.0
|
||||
margin: float = 0.0
|
||||
anchoring_value: float = 0.0
|
||||
candidates: list[TargetCandidate] = []
|
||||
chosen_basis: Optional[str] = None
|
||||
target_price: int = 0
|
||||
target_anchoring_price: Optional[int] = None
|
||||
|
||||
@ -20,6 +20,7 @@ from .protocol import (
|
||||
Res_QuotationSessions,
|
||||
Res_QuotationStatus,
|
||||
Res_SessionChat,
|
||||
Res_TargetBreakdown,
|
||||
)
|
||||
|
||||
# 라우터(컨트롤러). 인증(Depends(IsValidAccessToken))으로 UserInfo 를 받는다.
|
||||
@ -84,6 +85,11 @@ async def get_session_chat(session_id: UUID, service: QuotationService = Depends
|
||||
return RemoveNoneResponse(await service.list_chats(str(session_id)))
|
||||
|
||||
|
||||
@router.get(path="/session/{session_id}/target-breakdown", response_model=Res_TargetBreakdown, summary="세션 목표가 산정내역")
|
||||
async def get_target_breakdown(session_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
return RemoveNoneResponse(await service.get_target_breakdown(str(session_id)))
|
||||
|
||||
|
||||
@router.post(path="/session/{session_id}/notify", response_model=Res_NotifySessions, summary="세션 초청 메일 재발송")
|
||||
async def notify_session(session_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||
return RemoveNoneResponse(await service.notify_session(str(session_id)))
|
||||
|
||||
@ -30,6 +30,8 @@ from router.v1.quotation.protocol import (
|
||||
Res_QuotationSessions,
|
||||
Res_QuotationStatus,
|
||||
Res_SessionChat,
|
||||
Res_TargetBreakdown,
|
||||
TargetCandidate,
|
||||
)
|
||||
from services.email import EmailUnavailable, build_invite_email, send_email
|
||||
|
||||
@ -56,6 +58,9 @@ class QuotationService:
|
||||
# 인터넷 평균 수수료율(상수). 시장 평균값이라 견적/세팅별로 두지 않고 고정. 목표가=인터넷최저가×(1−값).
|
||||
INTERNET_AVERAGE_FEE = 0.078
|
||||
|
||||
# 목표가 후보 basis 코드 ↔ 표시 라벨(산정내역 응답에서 프론트가 그대로 표기).
|
||||
_CANDIDATE_LABELS = {"md": "MD 입력가", "internet": "인터넷 최저가", "purchase": "매입가", "selling": "판매가"}
|
||||
|
||||
def __init__(self, quotation_crud: IQuotationCRUD = Depends(QuotationCRUD)):
|
||||
self.quotation_crud = quotation_crud
|
||||
|
||||
@ -65,6 +70,23 @@ class QuotationService:
|
||||
base = (web_server_config.nego_chat_url or "").rstrip("/")
|
||||
return f"{base}/chat?session_id={session_id}"
|
||||
|
||||
@staticmethod
|
||||
def _candidates(md_price=None, internet_lowest=None, purchase=None, selling=None, fee=0.0, margin=0.0, is_new=False):
|
||||
"""목표가 후보 [(basis, value_float)] 목록(빈 값/0 은 제외). md 있으면 md 단독.
|
||||
값은 float(인터넷=가격×(1−수수료), 판매가=가격×(1−마진))이며 채택 시 int() 절삭한다.
|
||||
_calc_target_price(생성)와 get_target_breakdown(표시)가 공유하는 단일 산정 로직."""
|
||||
if md_price:
|
||||
return [("md", float(int(md_price)))]
|
||||
out = []
|
||||
if internet_lowest:
|
||||
out.append(("internet", int(internet_lowest) * (1 - (fee or 0.0))))
|
||||
if not is_new: # 재(협상·견적)만 매입가·판매가를 후보에 추가. 신규는 인터넷최저가만.
|
||||
if purchase:
|
||||
out.append(("purchase", float(int(purchase))))
|
||||
if selling:
|
||||
out.append(("selling", int(selling) * (1 - (margin or 0.0))))
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
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 적응)
|
||||
@ -76,24 +98,16 @@ class QuotationService:
|
||||
- 매입가 (그대로)
|
||||
- 판매가 × (1 − margin) ← margin=quotation_settings.target_margin_rate
|
||||
③ 후보 0개 → 견적 생성 불가(ValueError)."""
|
||||
if md_price:
|
||||
return int(md_price)
|
||||
# 율은 비율(0~1 미만)이어야 한다. 1 이상이면 (1−율)≤0 → 목표가가 0/음수가 되므로 설정 오류로 막는다.
|
||||
if not 0.0 <= (fee or 0.0) < 1.0:
|
||||
raise ValueError(f"인터넷 수수료율은 0 이상 1 미만이어야 합니다: fee={fee}")
|
||||
if not is_new and not 0.0 <= (margin or 0.0) < 1.0:
|
||||
raise ValueError(f"목표 마진율은 0 이상 1 미만이어야 합니다: margin={margin}")
|
||||
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:
|
||||
if not md_price:
|
||||
# 율은 비율(0~1 미만)이어야 한다. 1 이상이면 (1−율)≤0 → 목표가가 0/음수가 되므로 설정 오류로 막는다.
|
||||
if not 0.0 <= (fee or 0.0) < 1.0:
|
||||
raise ValueError(f"인터넷 수수료율은 0 이상 1 미만이어야 합니다: fee={fee}")
|
||||
if not is_new and not 0.0 <= (margin or 0.0) < 1.0:
|
||||
raise ValueError(f"목표 마진율은 0 이상 1 미만이어야 합니다: margin={margin}")
|
||||
cands = QuotationService._candidates(md_price, internet_lowest, purchase, selling, fee, margin, is_new)
|
||||
if not cands:
|
||||
raise ValueError("타겟 가격 계산 불가: md_price·인터넷최저가" + ("" if is_new else "·매입가·판매가") + " 모두 없음")
|
||||
return int(min(candidates))
|
||||
return int(min(v for _, v in cands))
|
||||
|
||||
@staticmethod
|
||||
def _gen_number() -> str:
|
||||
@ -112,6 +126,65 @@ class QuotationService:
|
||||
return ErrorType.QUOTATION_NOT_FOUND, None
|
||||
return ErrorType.SUCCESS, quotation
|
||||
|
||||
async def get_target_breakdown(self, session_id: str) -> Res_TargetBreakdown:
|
||||
"""세션 목표가 산정내역(후보·채택). 저장된 target_price/anchoring 은 그대로 표기하고,
|
||||
후보값은 생성과 동일한 _candidates 로직으로 계산해 내려준다(프론트 재계산 제거 → 항상 일치).
|
||||
상속분(재생성 라운드)은 현재 후보와 무관하므로 is_inherited=True, 채택 표시는 비운다."""
|
||||
res = Res_TargetBreakdown()
|
||||
err_type, got = await DB_SESSION_MNG.execute_lambda(
|
||||
sessions.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.quotation_crud.get_session_with_supplier(s, uuid.UUID(session_id)),
|
||||
)
|
||||
if err_type != ErrorType.SUCCESS or got is None:
|
||||
res.result.SetResult(err_type if err_type != ErrorType.SUCCESS else ErrorType.QUOTATION_NOT_FOUND)
|
||||
return res
|
||||
sess = got[0]
|
||||
err_type, quotation = await self._fetch(sess.quotation_id)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
|
||||
_e, prices = await DB_SESSION_MNG.execute_lambda(
|
||||
quotations.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.quotation_crud.get_item_prices(s, [sess.item_id]),
|
||||
)
|
||||
internet, purchase, selling = (prices or {}).get(sess.item_id) or (None, None, None)
|
||||
_e, rates = await DB_SESSION_MNG.execute_lambda(
|
||||
quotations.DBType(),
|
||||
DBWRType.DB_READ.value,
|
||||
lambda s: self.quotation_crud.get_setting_rates(s, quotation.qt_setting_id),
|
||||
)
|
||||
rates = rates or {}
|
||||
fee = self.INTERNET_AVERAGE_FEE
|
||||
margin = rates.get("margin") or 0.0
|
||||
anchoring = rates.get("anchoring") or 0.0
|
||||
is_new = QuotationType.is_new(quotation.type)
|
||||
md = quotation.md_price
|
||||
|
||||
cands = self._candidates(md, internet, purchase, selling, fee, margin, is_new)
|
||||
chosen_basis, computed = None, None
|
||||
if cands:
|
||||
chosen_basis, chosen_val = min(cands, key=lambda c: c[1])
|
||||
computed = int(chosen_val)
|
||||
is_inherited = computed is None or computed != sess.target_price
|
||||
|
||||
res.is_new = is_new
|
||||
res.is_inherited = is_inherited
|
||||
res.md_price = int(md) if md else None
|
||||
res.internet_lowest = int(internet) if internet is not None else None
|
||||
res.purchase = int(purchase) if purchase is not None else None
|
||||
res.selling = int(selling) if selling is not None else None
|
||||
res.fee = fee
|
||||
res.margin = margin
|
||||
res.anchoring_value = anchoring
|
||||
res.candidates = [TargetCandidate(basis=b, label=self._CANDIDATE_LABELS.get(b, b), value=int(v)) for b, v in cands]
|
||||
res.chosen_basis = None if is_inherited else chosen_basis
|
||||
res.target_price = sess.target_price
|
||||
res.target_anchoring_price = sess.target_anchoring_price
|
||||
return res
|
||||
|
||||
async def list_quotations(self, search, status, type_, start_from, start_to, pg: PageParams) -> Res_QuotationList:
|
||||
res = Res_QuotationList(page=pg.page, size=pg.size)
|
||||
|
||||
@ -353,7 +426,7 @@ class QuotationService:
|
||||
|
||||
# 상품 × 공급사 조합마다 세션 1개. md/매입/판매/인터넷 후보가 하나도 없으면 목표가 산정 불가 → 생성 실패.
|
||||
# 신규(NEW_NEGO/NEW_QUOTE)는 인터넷최저가만, 재(RENEGO/REQUOTE)는 매입가·판매가까지 후보(KTC 신규/재 분리).
|
||||
is_new = type_ in (QuotationType.NEW_NEGO.value, QuotationType.NEW_QUOTE.value)
|
||||
is_new = QuotationType.is_new(type_)
|
||||
session_objs = []
|
||||
try:
|
||||
for iid in item_ids:
|
||||
|
||||
@ -290,6 +290,14 @@ export * from './resSupplierList';
|
||||
export * from './resSupplierListMsg';
|
||||
export * from './resSupplierMsg';
|
||||
export * from './resSupplierSupplier';
|
||||
export * from './resTargetBreakdown';
|
||||
export * from './resTargetBreakdownChosenBasis';
|
||||
export * from './resTargetBreakdownInternetLowest';
|
||||
export * from './resTargetBreakdownMdPrice';
|
||||
export * from './resTargetBreakdownMsg';
|
||||
export * from './resTargetBreakdownPurchase';
|
||||
export * from './resTargetBreakdownSelling';
|
||||
export * from './resTargetBreakdownTargetAnchoringPrice';
|
||||
export * from './sessionData';
|
||||
export * from './sessionDataBidAt';
|
||||
export * from './sessionDataBidPrice';
|
||||
@ -308,6 +316,7 @@ export * from './supplierDataManagerName';
|
||||
export * from './supplierDataPriority';
|
||||
export * from './supplierDataUpdatedAt';
|
||||
export * from './supplierType';
|
||||
export * from './targetCandidate';
|
||||
export * from './userRole';
|
||||
export * from './userStatus';
|
||||
export * from './validationError';
|
||||
|
||||
33
negodata/front/src/api/generated/model/resTargetBreakdown.ts
Normal file
33
negodata/front/src/api/generated/model/resTargetBreakdown.ts
Normal file
@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 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 { ResTargetBreakdownMsg } from './resTargetBreakdownMsg';
|
||||
import type { ResTargetBreakdownMdPrice } from './resTargetBreakdownMdPrice';
|
||||
import type { ResTargetBreakdownInternetLowest } from './resTargetBreakdownInternetLowest';
|
||||
import type { ResTargetBreakdownPurchase } from './resTargetBreakdownPurchase';
|
||||
import type { ResTargetBreakdownSelling } from './resTargetBreakdownSelling';
|
||||
import type { TargetCandidate } from './targetCandidate';
|
||||
import type { ResTargetBreakdownChosenBasis } from './resTargetBreakdownChosenBasis';
|
||||
import type { ResTargetBreakdownTargetAnchoringPrice } from './resTargetBreakdownTargetAnchoringPrice';
|
||||
|
||||
export interface ResTargetBreakdown {
|
||||
result?: ErrorInfo;
|
||||
msg?: ResTargetBreakdownMsg;
|
||||
is_new?: boolean;
|
||||
is_inherited?: boolean;
|
||||
md_price?: ResTargetBreakdownMdPrice;
|
||||
internet_lowest?: ResTargetBreakdownInternetLowest;
|
||||
purchase?: ResTargetBreakdownPurchase;
|
||||
selling?: ResTargetBreakdownSelling;
|
||||
fee?: number;
|
||||
margin?: number;
|
||||
anchoring_value?: number;
|
||||
candidates?: TargetCandidate[];
|
||||
chosen_basis?: ResTargetBreakdownChosenBasis;
|
||||
target_price?: number;
|
||||
target_anchoring_price?: ResTargetBreakdownTargetAnchoringPrice;
|
||||
}
|
||||
@ -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 ResTargetBreakdownChosenBasis = 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 ResTargetBreakdownInternetLowest = 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 ResTargetBreakdownMdPrice = 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 ResTargetBreakdownMsg = 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 ResTargetBreakdownPurchase = 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 ResTargetBreakdownSelling = 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 ResTargetBreakdownTargetAnchoringPrice = number | null;
|
||||
12
negodata/front/src/api/generated/model/targetCandidate.ts
Normal file
12
negodata/front/src/api/generated/model/targetCandidate.ts
Normal file
@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Generated by orval v7.21.0 🍺
|
||||
* Do not edit manually.
|
||||
* Negodata Api Server
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export interface TargetCandidate {
|
||||
basis: string;
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
@ -38,7 +38,8 @@ import type {
|
||||
ResQuotationResult,
|
||||
ResQuotationSessions,
|
||||
ResQuotationStatus,
|
||||
ResSessionChat
|
||||
ResSessionChat,
|
||||
ResTargetBreakdown
|
||||
} from '.././model';
|
||||
|
||||
import { customFetch } from '../../mutator/custom-fetch';
|
||||
@ -670,6 +671,98 @@ export function useGetSessionChat<TData = Awaited<ReturnType<typeof getSessionCh
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @summary 세션 목표가 산정내역
|
||||
*/
|
||||
export const getTargetBreakdown = (
|
||||
sessionId: string,
|
||||
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
|
||||
) => {
|
||||
|
||||
|
||||
return customFetch<ResTargetBreakdown>(
|
||||
{url: `/v1/quotation/session/${sessionId}/target-breakdown`, method: 'GET', signal
|
||||
},
|
||||
options);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
export const getGetTargetBreakdownQueryKey = (sessionId?: string,) => {
|
||||
return [
|
||||
`/v1/quotation/session/${sessionId}/target-breakdown`
|
||||
] as const;
|
||||
}
|
||||
|
||||
|
||||
export const getGetTargetBreakdownQueryOptions = <TData = Awaited<ReturnType<typeof getTargetBreakdown>>, TError = void | HTTPValidationError>(sessionId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getTargetBreakdown>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||
) => {
|
||||
|
||||
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetTargetBreakdownQueryKey(sessionId);
|
||||
|
||||
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getTargetBreakdown>>> = ({ signal }) => getTargetBreakdown(sessionId, requestOptions, signal);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return { queryKey, queryFn, enabled: !!(sessionId), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getTargetBreakdown>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
}
|
||||
|
||||
export type GetTargetBreakdownQueryResult = NonNullable<Awaited<ReturnType<typeof getTargetBreakdown>>>
|
||||
export type GetTargetBreakdownQueryError = void | HTTPValidationError
|
||||
|
||||
|
||||
export function useGetTargetBreakdown<TData = Awaited<ReturnType<typeof getTargetBreakdown>>, TError = void | HTTPValidationError>(
|
||||
sessionId: string, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getTargetBreakdown>>, TError, TData>> & Pick<
|
||||
DefinedInitialDataOptions<
|
||||
Awaited<ReturnType<typeof getTargetBreakdown>>,
|
||||
TError,
|
||||
Awaited<ReturnType<typeof getTargetBreakdown>>
|
||||
> , 'initialData'
|
||||
>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
export function useGetTargetBreakdown<TData = Awaited<ReturnType<typeof getTargetBreakdown>>, TError = void | HTTPValidationError>(
|
||||
sessionId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getTargetBreakdown>>, TError, TData>> & Pick<
|
||||
UndefinedInitialDataOptions<
|
||||
Awaited<ReturnType<typeof getTargetBreakdown>>,
|
||||
TError,
|
||||
Awaited<ReturnType<typeof getTargetBreakdown>>
|
||||
> , 'initialData'
|
||||
>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
export function useGetTargetBreakdown<TData = Awaited<ReturnType<typeof getTargetBreakdown>>, TError = void | HTTPValidationError>(
|
||||
sessionId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getTargetBreakdown>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
|
||||
/**
|
||||
* @summary 세션 목표가 산정내역
|
||||
*/
|
||||
|
||||
export function useGetTargetBreakdown<TData = Awaited<ReturnType<typeof getTargetBreakdown>>, TError = void | HTTPValidationError>(
|
||||
sessionId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getTargetBreakdown>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
|
||||
, queryClient?: QueryClient
|
||||
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
|
||||
|
||||
const queryOptions = getGetTargetBreakdownQueryOptions(sessionId,options)
|
||||
|
||||
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||
|
||||
query.queryKey = queryOptions.queryKey ;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @summary 세션 초청 메일 재발송
|
||||
*/
|
||||
|
||||
@ -10,6 +10,7 @@ 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 { CARD_USAGE_TYPE_LABEL, CARD_USAGE_TYPE_OPTIONS } from '@/lib/enumLabels';
|
||||
import type { CardInput } from '../hooks/useCards';
|
||||
import { CardScriptEditor, deserialize, serializeToText } from '../editor';
|
||||
|
||||
@ -235,18 +236,13 @@ export function CardFormSheet({
|
||||
<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)
|
||||
? '재견적전용'
|
||||
: '공통'}
|
||||
{(value) => CARD_USAGE_TYPE_LABEL[Number(value) as CardUsageType] ?? '공통'}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={String(CardUsageType.COMMON)}>공통</SelectItem>
|
||||
<SelectItem value={String(CardUsageType.NEW)}>신규견적전용</SelectItem>
|
||||
<SelectItem value={String(CardUsageType.REUSE)}>재견적전용</SelectItem>
|
||||
{CARD_USAGE_TYPE_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={String(o.value)}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
@ -1,14 +1,8 @@
|
||||
import { UserRole } from '@/api/generated/model';
|
||||
import { UserRole, UserStatus } from '@/api/generated/model';
|
||||
|
||||
// 회사 유저(계정) 상태 코드 — 백엔드 UserStatus 미러.
|
||||
// (orval 재생성 전까지 로컬 정의. 재생성 후 @/api/generated/model 의 UserStatus 로 교체 가능)
|
||||
export const UserStatus = { ACTIVE: 1, INACTIVE: 2 } as const;
|
||||
export type UserStatus = (typeof UserStatus)[keyof typeof UserStatus];
|
||||
|
||||
export const USER_STATUS_LABEL: Record<UserStatus, string> = {
|
||||
[UserStatus.ACTIVE]: '활성',
|
||||
[UserStatus.INACTIVE]: '비활성',
|
||||
};
|
||||
// 유저 상태 코드/라벨은 생성 enum + 중앙 라벨(lib/enumLabels.ts) 단일 출처를 그대로 재노출한다.
|
||||
export { UserStatus };
|
||||
export { USER_STATUS_LABEL } from '@/lib/enumLabels';
|
||||
|
||||
// 백엔드 CompanyUserData 와 1:1.
|
||||
export interface CompanyUserData {
|
||||
|
||||
@ -10,7 +10,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, supplierTypeOptions } from '../types';
|
||||
import { QUOTATION_TYPE_OPTIONS, supplierTypeOptions, isNewQuotationType } from '../types';
|
||||
import { supplierTypeLabel } from '@/lib/enumLabels';
|
||||
import { showToast } from '@/lib/notify';
|
||||
|
||||
// datetime-local 디폴트값: 현재 한국시간(Asia/Seoul)의 'YYYY-MM-DDTHH:mm'.
|
||||
@ -68,7 +69,7 @@ export function QuotationCreateModal({
|
||||
const navigate = useNavigate();
|
||||
// 인터넷최저가·매입가·판매가는 상품 속성 — 모달에선 읽기전용으로만 보여주고, 수정은 상품 상세에서 한다.
|
||||
const selectedProduct = products.find((p) => p.id === productId);
|
||||
const isReType = type === QuotationType.RENEGO || type === QuotationType.REQUOTE;
|
||||
const isReType = !isNewQuotationType(type);
|
||||
const internetLowest = selectedProduct?.internet_lowest_price ?? null;
|
||||
const purchase = selectedProduct?.purchase_price ?? null;
|
||||
const selling = selectedProduct?.selling_price ?? null;
|
||||
@ -329,11 +330,7 @@ export function QuotationCreateModal({
|
||||
<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 ?? ''
|
||||
: '협력사 유형 선택...'
|
||||
}
|
||||
{(value) => (value ? supplierTypeLabel(Number(value)) : '협력사 유형 선택...')}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@ -1,74 +1,40 @@
|
||||
import { X, Check } from 'lucide-react';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { useGetTargetBreakdown } from '@/api/generated/quotation/quotation';
|
||||
|
||||
// 세션 목표가 산정내역 모달. backend _calc_target_price 로직을 그대로 재구성해 후보·채택을 보여준다.
|
||||
// (신규견적은 인터넷최저가만 후보, 재는 매입가·판매가까지 / md 입력가 최우선. 앵커링은 설정 anchoring_value 고정율.)
|
||||
// 세션 목표가 산정내역 모달. 후보·채택·앵커링가는 백엔드 /target-breakdown 이 산정한 값을 '표시만' 한다.
|
||||
// (프론트 재계산 없음 → 저장된 목표가와 항상 일치. 산정 로직은 백엔드 _candidates 단일 출처.)
|
||||
type TargetPriceModalProps = {
|
||||
onClose: () => void;
|
||||
sessionId: string;
|
||||
qtNumber: string;
|
||||
itemName: string;
|
||||
vatYn?: boolean | null;
|
||||
deliveryFeeYn?: boolean | null;
|
||||
category?: string | null;
|
||||
supplierTypeLabel: string;
|
||||
targetPrice: number;
|
||||
anchoringPrice: number;
|
||||
mdPrice?: number | null;
|
||||
internetLowest?: number | null;
|
||||
purchase?: number | null;
|
||||
selling?: number | null;
|
||||
fee: number;
|
||||
margin: number;
|
||||
anchoringValue: number;
|
||||
isNew: boolean;
|
||||
};
|
||||
|
||||
const won = (n: number | null) => (n != null ? `₩${n.toLocaleString()}` : '-');
|
||||
const won = (n?: number | null) => (n != null ? `₩${n.toLocaleString()}` : '-');
|
||||
|
||||
type CandKey = 'md' | 'net' | 'sell' | 'buy';
|
||||
// 후보 basis 별 부가 설명(수수료/마진 적용 표시). 백엔드 라벨에 없는 보조 문구만 프론트가 덧붙인다.
|
||||
const CANDIDATE_SUB: Record<string, string> = {
|
||||
internet: '인터넷 평균 수수료 적용',
|
||||
selling: '목표 마진율 적용',
|
||||
};
|
||||
|
||||
export function TargetPriceModal({
|
||||
onClose,
|
||||
sessionId,
|
||||
qtNumber,
|
||||
itemName,
|
||||
vatYn,
|
||||
deliveryFeeYn,
|
||||
category,
|
||||
supplierTypeLabel,
|
||||
targetPrice,
|
||||
anchoringPrice,
|
||||
mdPrice,
|
||||
internetLowest,
|
||||
purchase,
|
||||
selling,
|
||||
fee,
|
||||
margin,
|
||||
anchoringValue,
|
||||
isNew,
|
||||
}: TargetPriceModalProps) {
|
||||
// 후보값 계산 (신규는 인터넷최저가만, 재는 매입가·판매가까지)
|
||||
const md = mdPrice && mdPrice > 0 ? Math.round(mdPrice) : null;
|
||||
const net = internetLowest && internetLowest > 0 ? Math.round(internetLowest * (1 - fee)) : null;
|
||||
const sell = !isNew && selling && selling > 0 ? Math.round(selling * (1 - margin)) : null;
|
||||
const buy = !isNew && purchase && purchase > 0 ? Math.round(purchase) : null;
|
||||
|
||||
// 채택 후보: md 최우선, 없으면 유효 후보 중 최소값
|
||||
let applied: CandKey | null = null;
|
||||
if (md != null) {
|
||||
applied = 'md';
|
||||
} else {
|
||||
const pool = ([['net', net], ['sell', sell], ['buy', buy]] as [CandKey, number | null][]).filter(
|
||||
(c): c is [CandKey, number] => c[1] != null,
|
||||
);
|
||||
if (pool.length) applied = pool.reduce((m, c) => (c[1] < m[1] ? c : m))[0];
|
||||
}
|
||||
|
||||
const rows: { key: CandKey; label: string; sub?: string; value: number | null }[] = [
|
||||
{ key: 'md', label: 'MD 입력가', value: md },
|
||||
{ key: 'net', label: '인터넷 최저가', sub: '인터넷 평균 수수료 적용', value: net },
|
||||
{ key: 'sell', label: '판매가', sub: '목표 마진율 적용', value: sell },
|
||||
{ key: 'buy', label: '매입가', value: buy },
|
||||
];
|
||||
const { data: bd, isLoading } = useGetTargetBreakdown(sessionId, { query: { enabled: !!sessionId } });
|
||||
const candidates = bd?.candidates ?? [];
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -93,69 +59,83 @@ export function TargetPriceModal({
|
||||
배송비: {deliveryFeeYn ? '배송비포함' : '배송비별도'} · 부가세: {vatYn ? 'VAT포함' : 'VAT별도'}
|
||||
</Typography>
|
||||
|
||||
{/* 목표가 */}
|
||||
<div className="flex items-center justify-between gap-3 mt-5">
|
||||
<Typography variant="label" className="font-bold">목표가</Typography>
|
||||
<div className="flex-1 text-right bg-muted/50 border border-border rounded px-3 py-2 font-bold text-base text-foreground">
|
||||
{won(targetPrice)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 선정방식 */}
|
||||
<div className="mt-4 bg-muted/40 border border-border rounded p-3 space-y-1">
|
||||
<Typography as="p" variant="small" className="font-bold text-foreground text-[11px]">목표가 선정방식</Typography>
|
||||
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">1. MD 입력값 존재 시, 최우선 적용</Typography>
|
||||
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">2. 다음 중 가장 작은 값 — 인터넷최저가×(1−수수료) | 매입가 | 판매가×(1−목표 마진율)</Typography>
|
||||
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground">
|
||||
* 인터넷 평균 수수료: {fee} · 목표 마진율: {margin}{isNew ? ' · 신규견적이라 인터넷최저가만 적용' : ''}
|
||||
{isLoading || !bd ? (
|
||||
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground mt-6">
|
||||
산정내역을 불러오는 중…
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
{/* 후보 */}
|
||||
<div className="mt-4 space-y-2">
|
||||
{rows.map((r) => {
|
||||
const on = r.key === applied;
|
||||
return (
|
||||
<div key={r.key} className="flex items-center gap-2">
|
||||
<span className={`w-4 shrink-0 ${on ? 'text-emerald-600' : 'text-transparent'}`}>
|
||||
{on ? <Check size={14} /> : null}
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<Typography as="span" variant="small" className={`text-[11px] ${on ? 'font-bold text-foreground' : 'text-muted-foreground'}`}>
|
||||
{r.label}
|
||||
</Typography>
|
||||
{r.sub && (
|
||||
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground ml-1">
|
||||
({r.sub})
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`text-right min-w-[96px] px-2 py-1 rounded border ${
|
||||
on
|
||||
? 'border-emerald-300 bg-emerald-50 dark:bg-emerald-950/20 font-bold text-foreground'
|
||||
: 'border-border bg-muted/30 text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{won(r.value)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 목표가 */}
|
||||
<div className="flex items-center justify-between gap-3 mt-5">
|
||||
<Typography variant="label" className="font-bold">목표가</Typography>
|
||||
<div className="flex-1 text-right bg-muted/50 border border-border rounded px-3 py-2 font-bold text-base text-foreground">
|
||||
{won(bd.target_price)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 앵커링 (negodata: 설정 anchoring_value 고정율) */}
|
||||
<div className="mt-4 bg-muted/40 border border-border rounded p-3 space-y-1">
|
||||
<Typography as="p" variant="small" className="font-bold text-foreground text-[11px]">앵커링</Typography>
|
||||
{category && (
|
||||
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">서비스 카테고리: {category}</Typography>
|
||||
)}
|
||||
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">공급 업체 유형: {supplierTypeLabel}</Typography>
|
||||
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">앵커링 값: {anchoringValue}</Typography>
|
||||
<Typography as="p" variant="small" className="text-[11px] font-bold text-foreground">
|
||||
앵커링가: {won(anchoringPrice)} <span className="font-normal text-[10px] text-muted-foreground">= 목표가×(1−{anchoringValue})</span>
|
||||
</Typography>
|
||||
</div>
|
||||
{/* 선정방식 */}
|
||||
<div className="mt-4 bg-muted/40 border border-border rounded p-3 space-y-1">
|
||||
<Typography as="p" variant="small" className="font-bold text-foreground text-[11px]">목표가 선정방식</Typography>
|
||||
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">1. MD 입력값 존재 시, 최우선 적용</Typography>
|
||||
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">2. 다음 중 가장 작은 값 — 인터넷최저가×(1−수수료) | 매입가 | 판매가×(1−목표 마진율)</Typography>
|
||||
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground">
|
||||
* 인터넷 평균 수수료: {bd.fee} · 목표 마진율: {bd.margin}{bd.is_new ? ' · 신규견적이라 인터넷최저가만 적용' : ''}
|
||||
</Typography>
|
||||
{bd.is_inherited && (
|
||||
<Typography as="p" variant="small" className="text-[10px] text-amber-600">
|
||||
* 저장된 목표가가 현재 후보 최소값과 다름 — 재생성 상속 또는 산정 후 상품·세팅 변경(아래 후보는 현재값 기준 참고용)
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 후보 */}
|
||||
<div className="mt-4 space-y-2">
|
||||
{candidates.map((c) => {
|
||||
const on = c.basis === bd.chosen_basis;
|
||||
const sub = CANDIDATE_SUB[c.basis];
|
||||
return (
|
||||
<div key={c.basis} className="flex items-center gap-2">
|
||||
<span className={`w-4 shrink-0 ${on ? 'text-emerald-600' : 'text-transparent'}`}>
|
||||
{on ? <Check size={14} /> : null}
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<Typography as="span" variant="small" className={`text-[11px] ${on ? 'font-bold text-foreground' : 'text-muted-foreground'}`}>
|
||||
{c.label}
|
||||
</Typography>
|
||||
{sub && (
|
||||
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground ml-1">
|
||||
({sub})
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`text-right min-w-[96px] px-2 py-1 rounded border ${
|
||||
on
|
||||
? 'border-emerald-300 bg-emerald-50 dark:bg-emerald-950/20 font-bold text-foreground'
|
||||
: 'border-border bg-muted/30 text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{won(c.value)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 앵커링 (negodata: 설정 anchoring_value 고정율) */}
|
||||
<div className="mt-4 bg-muted/40 border border-border rounded p-3 space-y-1">
|
||||
<Typography as="p" variant="small" className="font-bold text-foreground text-[11px]">앵커링</Typography>
|
||||
{category && (
|
||||
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">서비스 카테고리: {category}</Typography>
|
||||
)}
|
||||
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">공급 업체 유형: {supplierTypeLabel}</Typography>
|
||||
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">앵커링 값: {bd.anchoring_value}</Typography>
|
||||
<Typography as="p" variant="small" className="text-[11px] font-bold text-foreground">
|
||||
앵커링가: {won(bd.target_anchoring_price)} <span className="font-normal text-[10px] text-muted-foreground">= 목표가×(1−{bd.anchoring_value})</span>
|
||||
</Typography>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -19,9 +19,9 @@ import {
|
||||
mapSetting,
|
||||
mapServerSessionView,
|
||||
mapServerCardView,
|
||||
supplierTypeOptions,
|
||||
} from '../../types';
|
||||
import { QuotationStatus, QuotationType } from '@/api/generated/model';
|
||||
import { supplierTypeLabel } from '@/lib/enumLabels';
|
||||
import { QuotationStatus } from '@/api/generated/model';
|
||||
import { DrawerHeaderCards } from './DrawerHeaderCards';
|
||||
import { RoundTimeline } from './RoundTimeline';
|
||||
import { RegenerateModal } from './RegenerateModal';
|
||||
@ -274,26 +274,16 @@ export function QuotationDetailSheet({
|
||||
{targetSessionId && currentItem && (() => {
|
||||
const ts = sessionViews.find((s) => s.session_id === targetSessionId);
|
||||
if (!ts) return null;
|
||||
const rawSetting = settingsQuery.data?.settings?.find((s) => s.qt_setting_id === quotation.qt_setting_id);
|
||||
return (
|
||||
<TargetPriceModal
|
||||
onClose={() => setTargetSessionId(null)}
|
||||
sessionId={targetSessionId}
|
||||
qtNumber={quotation.number ?? '-'}
|
||||
itemName={currentItem.name ?? ts.item_name ?? '-'}
|
||||
vatYn={currentItem.vat_yn}
|
||||
deliveryFeeYn={currentItem.delivery_fee_yn}
|
||||
category={currentItem.category}
|
||||
supplierTypeLabel={supplierTypeOptions.find((o) => o.value === quotation.supplier_type)?.label ?? '-'}
|
||||
targetPrice={ts.target_price}
|
||||
anchoringPrice={ts.target_anchoring_price}
|
||||
mdPrice={quotation.md_price}
|
||||
internetLowest={currentItem.internet_lowest_price}
|
||||
purchase={currentItem.purchase_price}
|
||||
selling={currentItem.selling_price}
|
||||
fee={0.078}
|
||||
margin={rawSetting?.target_margin_rate ?? 0}
|
||||
anchoringValue={rawSetting?.anchoring_value ?? 0}
|
||||
isNew={quotation.type === QuotationType.NEW_NEGO || quotation.type === QuotationType.NEW_QUOTE}
|
||||
supplierTypeLabel={supplierTypeLabel(quotation.supplier_type)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
@ -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, SupplierType } from '@/api/generated/model';
|
||||
import { QuotationType, QuotationStatus, SessionStatus, CardType } from '@/api/generated/model';
|
||||
import { DELIVERY_TYPE_LABEL } from '@/lib/enumLabels';
|
||||
import type { Product, Partner, NegotiationCard } from '@/types';
|
||||
|
||||
@ -149,14 +149,12 @@ export const QUOTATION_TYPE_OPTIONS = [
|
||||
value,
|
||||
label: QUOTATION_TYPE_LABEL[value],
|
||||
}));
|
||||
// 신규(NEW_*) vs 재(RE*) 구분. 가격 산정·후보 노출이 이 분기에 의존하므로 한 곳에서만 판단한다.
|
||||
export const isNewQuotationType = (t?: number | null): boolean =>
|
||||
t === QuotationType.NEW_NEGO || t === QuotationType.NEW_QUOTE;
|
||||
|
||||
// 협력사 유형 선택지(견적생성 모달). 재견적은 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.NONE, label: '없음' },
|
||||
];
|
||||
// 협력사 유형 선택지(견적생성 모달) — 라벨은 lib/enumLabels.ts 의 SUPPLIER_TYPE 단일 출처에서 파생.
|
||||
export { SUPPLIER_TYPE_OPTIONS as supplierTypeOptions } from '@/lib/enumLabels';
|
||||
|
||||
// ── 라운드 체인(같은 견적번호) ───────────────────────────────────────────
|
||||
// 한 라운드(견적)의 결과를 한 단어로. 낙찰=종료, 동가/마감=후속 라운드 가능, 진행중=아직 안 닫힘.
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { DeliveryType, UserRole } from '@/api/generated/model';
|
||||
import { DeliveryType, UserRole, SupplierType, CardUsageType, UserStatus } from '@/api/generated/model';
|
||||
|
||||
export const DELIVERY_TYPE_LABEL: Record<DeliveryType, string> = {
|
||||
[DeliveryType.PARTNER]: '협력사배송',
|
||||
@ -14,3 +14,35 @@ export const USER_ROLE_LABEL: Record<UserRole, string> = {
|
||||
[UserRole.USER]: '일반',
|
||||
[UserRole.OWNER]: '최고관리자',
|
||||
};
|
||||
|
||||
export const SUPPLIER_TYPE_LABEL: Record<SupplierType, string> = {
|
||||
[SupplierType.NONE]: '없음',
|
||||
[SupplierType.DISTRIBUTION]: '유통',
|
||||
[SupplierType.MANUFACTURE]: '제조',
|
||||
[SupplierType.SOLE_AGENCY]: '총판',
|
||||
};
|
||||
// 견적생성 모달 선택지(없음 포함). 표시 순서: 유통/제조/총판/없음.
|
||||
export const SUPPLIER_TYPE_OPTIONS = [
|
||||
SupplierType.DISTRIBUTION,
|
||||
SupplierType.MANUFACTURE,
|
||||
SupplierType.SOLE_AGENCY,
|
||||
SupplierType.NONE,
|
||||
].map((value) => ({ value, label: SUPPLIER_TYPE_LABEL[value] }));
|
||||
export const supplierTypeLabel = (v?: number | null): string =>
|
||||
v == null ? '-' : SUPPLIER_TYPE_LABEL[v as SupplierType] ?? '-';
|
||||
|
||||
export const CARD_USAGE_TYPE_LABEL: Record<CardUsageType, string> = {
|
||||
[CardUsageType.COMMON]: '공통',
|
||||
[CardUsageType.NEW]: '신규견적전용',
|
||||
[CardUsageType.REUSE]: '재견적전용',
|
||||
};
|
||||
export const CARD_USAGE_TYPE_OPTIONS = [
|
||||
CardUsageType.COMMON,
|
||||
CardUsageType.NEW,
|
||||
CardUsageType.REUSE,
|
||||
].map((value) => ({ value, label: CARD_USAGE_TYPE_LABEL[value] }));
|
||||
|
||||
export const USER_STATUS_LABEL: Record<UserStatus, string> = {
|
||||
[UserStatus.ACTIVE]: '활성',
|
||||
[UserStatus.INACTIVE]: '비활성',
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user