[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:
Mina Choi 2026-06-30 08:40:30 +09:00
parent 019f3dbeac
commit 33ce82997a
22 changed files with 471 additions and 175 deletions

View File

@ -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). 프론트 견적상태 뱃지와 매핑된다."""

View File

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

View File

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

View File

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

View File

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

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

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 ResTargetBreakdownChosenBasis = 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 ResTargetBreakdownInternetLowest = 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 ResTargetBreakdownMdPrice = 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 ResTargetBreakdownMsg = 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 ResTargetBreakdownPurchase = 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 ResTargetBreakdownSelling = 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 ResTargetBreakdownTargetAnchoringPrice = number | null;

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

View File

@ -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 세션 초청 메일 재발송
*/

View File

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

View File

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

View File

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

View File

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

View File

@ -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)}
/>
);
})()}

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, 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';
// ── 라운드 체인(같은 견적번호) ───────────────────────────────────────────
// 한 라운드(견적)의 결과를 한 단어로. 낙찰=종료, 동가/마감=후속 라운드 가능, 진행중=아직 안 닫힘.

View File

@ -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]: '비활성',
};