- {q.customer?.name ?? '—'}
+ {q.customer?.name ?? '—'}
{QUOTE_STATUS_LABELS[q.status] ?? q.status}
@@ -1108,7 +1109,7 @@ function QuotesTab() {
const signed = q.contract?.status === 'COMPLETED'
return (
- | {q.quote_no ?? `#${q.id}`} |
+ {q.quote_no ?? `#${q.id}`} |
{q.customer?.name ?? '—'} |
{q.sales_user_id ?? '—'}
diff --git a/frontend/src/pages/SalesPage.tsx b/frontend/src/pages/SalesPage.tsx
index 64a863c..9b99fa3 100644
--- a/frontend/src/pages/SalesPage.tsx
+++ b/frontend/src/pages/SalesPage.tsx
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
+import { QuoteKindTag } from '../components/QuoteKindTag'
import { openPdf, reservePdfTab, openPdfIn, closeReservedTab } from '../lib/openPdf'
import { computeHidden, computeDisabledGroups, sanitizeSelections } from '../lib/optionRules'
import { buildLiveTotal } from '../lib/liveQuote'
@@ -474,7 +475,7 @@ function MyListView() {
const order = orderByQuote.get(q.id)
return (
|
- | {q.quote_no ?? `#${q.id}`} |
+ {q.quote_no ?? `#${q.id}`} |
{q.customer?.name ?? '—'} |
{fmtPrice(q.final_price)} |
@@ -686,6 +687,11 @@ export function SalesPage() {
* 냉동을 고르려면 V2L 확인이 필요하다(차량 전원으로 냉동기를 돌린다).
*/
const [bodyOnly, setBodyOnly] = useState(false)
+ /**
+ * 차량만 견적 — 특장을 얹지 않고 차량만 판다. 특장만의 거울상이다.
+ * 둘은 동시에 참일 수 없다(그러면 팔 것이 없다).
+ */
+ const [vehicleOnly, setVehicleOnly] = useState(false)
const [v2lConfirmed, setV2lConfirmed] = useState(false)
/** 보유 차종 — 특장만 견적의 전제라 임시저장 단계에서 받는다 */
const [ownedModel, setOwnedModel] = useState('')
@@ -785,9 +791,9 @@ export function SalesPage() {
const liveTotal = useMemo(
() => buildLiveTotal({
bundle, selections, subsidyInputs, subsidyLocal, subsidyReady,
- promotionZeroed, promotionDiscount, localSubsidyOff, customer, bodyOnly,
+ promotionZeroed, promotionDiscount, localSubsidyOff, customer, bodyOnly, vehicleOnly,
}),
- [bundle, selections, subsidyLocal, subsidyInputs, subsidyReady, customer, promotionZeroed, promotionDiscount, localSubsidyOff, bodyOnly],
+ [bundle, selections, subsidyLocal, subsidyInputs, subsidyReady, customer, promotionZeroed, promotionDiscount, localSubsidyOff, bodyOnly, vehicleOnly],
)
function handleSelect(groupCode: string, valueCode: string) {
@@ -853,6 +859,7 @@ export function SalesPage() {
...quotePriceExtras({ promotionZeroed, promotionDiscount, localSubsidyOff }),
// 특장만 견적 — 차량 금액·보조금이 빠진다. 보유 차종은 계약서 단계에서 받는다.
body_only: bodyOnly || undefined,
+ vehicle_only: vehicleOnly || undefined,
vehicle_owned: bodyOnly ? { model: ownedModel.trim() } : undefined,
customer: {
name: v.name.trim(),
@@ -1015,6 +1022,15 @@ export function SalesPage() {
promotionZeroed={promotionZeroed}
onTogglePromotion={togglePromotion}
bodyOnly={bodyOnly}
+ vehicleOnly={vehicleOnly}
+ onToggleVehicleOnly={v => {
+ setVehicleOnly(v)
+ /*
+ * 차량만으로 바꾸면 특장에 딸린 선택을 **지운다.** 남겨 두면 화면에서
+ * 안 보이는 옵션이 저장에 실려, 나중에 특장 견적으로 되돌릴 때 되살아난다.
+ */
+ if (v) { setPromotionZeroed(new Set()); setPromotionDiscount(0) }
+ }}
onToggleBodyOnly={v => {
setBodyOnly(v)
// 차량 구매로 되돌리면 확인은 무효다 — 그 확인은 고객 차량에 대한 것이었다
diff --git a/shared/pricing/core.ts b/shared/pricing/core.ts
index 3eee49a..71ce8b2 100644
--- a/shared/pricing/core.ts
+++ b/shared/pricing/core.ts
@@ -23,6 +23,7 @@ export {
export type { QuoteParams, QuoteResult, DieselStatus } from './quote.js';
// 특장만 견적 — 차량에 딸린 입력을 한 곳에서 0으로 만든다
export { bodyOnlyParams } from './body-only.js';
+export { vehicleOnlyParams } from './vehicle-only.js';
// 가격바가 보여줄 파생값(계산은 여기 한 곳에만 — 화면·앱이 공유한다)
export { priceBarView } from './pricebar.js';
diff --git a/shared/pricing/quote.ts b/shared/pricing/quote.ts
index cf12674..78600eb 100644
--- a/shared/pricing/quote.ts
+++ b/shared/pricing/quote.ts
@@ -77,6 +77,11 @@ export interface QuoteParams {
* 비용이라 차를 어디서 샀는지와 무관하다.
*/
body_only?: boolean;
+ /**
+ * 차량만 견적 — 특장을 얹지 않는다. [[body_only]] 의 거울상이다.
+ * 둘은 **동시에 참일 수 없다**(그러면 팔 것이 아무것도 없다).
+ */
+ vehicle_only?: boolean;
car_price: number; // D10 차량가격(트림, VAT포함)
delivery_fee: number; // D11 탁송료
commercial_discount: number; // D12 현대커머셜 할인
diff --git a/shared/pricing/vehicle-only.test.ts b/shared/pricing/vehicle-only.test.ts
new file mode 100644
index 0000000..03449ba
--- /dev/null
+++ b/shared/pricing/vehicle-only.test.ts
@@ -0,0 +1,95 @@
+import { describe, it, expect } from 'vitest';
+import { calcQuote } from './quote.js';
+import { vehicleOnlyParams } from './vehicle-only.js';
+import { bodyOnlyParams } from './body-only.js';
+import { QUOTE_PARAMS } from './fixtures.js';
+
+/**
+ * **차량만 견적** — 특장을 얹지 않고 차량만 판다. `bodyOnlyParams` 의 거울상이다.
+ *
+ * 여기서 지키는 것은 「특장 금액이 0」만이 아니다. **차량 쪽은 하나도 안 깎여야** 한다 —
+ * 보조금·할부가 빠지면 차량만 견적이 성립하지 않는다.
+ */
+const P = { ...QUOTE_PARAMS, down_payment_rate: 0.3, installment_months: 36 };
+const v = (over = {}) => calcQuote(vehicleOnlyParams({ ...P, ...over }));
+const full = calcQuote(P);
+
+describe('차량만 — 특장 쪽이 전부 빠진다', () => {
+ it('특장 금액이 0이다', () => {
+ const r = v();
+ expect(r.body_price).toBe(0);
+ expect(r.body_payment).toBe(0);
+ });
+
+ it('특장 계약금·취득세·등록부대비용이 0이다', () => {
+ const r = v();
+ expect(r.body_deposit).toBe(0);
+ expect(r.body_acq_tax).toBe(0);
+ expect(r.body_reg_cost).toBe(0);
+ });
+
+ it('🔴 구조변경 비용이 빠진다 — 구조변경 자체를 하지 않는다', () => {
+ // 특장을 안 얹으면 구조를 바꿀 일이 없다. 남겨 두면 없는 작업에 돈을 받는 셈이다
+ const r = v({ structure_change_fee: 400_000, etc_fee: 50_000 });
+ expect(r.body_reg_cost).toBe(0);
+ });
+
+ it('프로모션도 빠진다 — 특장 옵션에 붙는 할인이다', () => {
+ const r = v({ promotion: 1_000_000 });
+ expect(r.promotion).toBe(0);
+ });
+});
+
+describe('차량만 — 차량 쪽은 하나도 깎이지 않는다', () => {
+ it('차량가·탁송료가 그대로다', () => {
+ const r = v();
+ expect(r.car_price).toBe(full.car_price);
+ expect(r.delivery_fee).toBe(full.delivery_fee);
+ });
+
+ it('🔴 EV 보조금이 그대로 붙는다 — 차를 사는 거래다', () => {
+ const r = v();
+ expect(r.subsidy_total).toBe(full.subsidy_total);
+ // 보조금은 **차감액이라 음수**로 담긴다 — 0 이 아니라는 것이 요점이다
+ expect(Math.abs(r.subsidy_total)).toBeGreaterThan(0);
+ });
+
+ it('🔴 할부(캐피탈)가 그대로 걸린다 — 특장만 견적과 정반대다', () => {
+ const r = v();
+ expect(r.installment_months).toBe(36);
+ expect(r.down_payment).toBeGreaterThan(0);
+ expect(r.monthly_payment).toBeGreaterThan(0);
+ });
+
+ it('차량 취득세·등록비가 그대로다', () => {
+ const r = v();
+ expect(r.car_acq_tax).toBe(full.car_acq_tax);
+ expect(r.car_reg_cost).toBe(full.car_reg_cost);
+ });
+});
+
+describe('특장만 견적과 정확히 반대다', () => {
+ it('둘을 합치면 원래 견적의 결제금액이 된다', () => {
+ /*
+ * 차량만 결제금액 + 특장만 결제금액 = 원래 결제금액.
+ * 한쪽 변환이 남의 몫까지 건드리면 이 등식이 깨진다.
+ */
+ const onlyV = calcQuote(vehicleOnlyParams(P));
+ const onlyB = calcQuote(bodyOnlyParams(P));
+ expect(onlyV.car_payment + onlyB.body_payment).toBe(full.car_payment + full.body_payment);
+ });
+
+ it('할부는 서로 반대다', () => {
+ expect(calcQuote(vehicleOnlyParams(P)).installment_months).toBe(36);
+ expect(calcQuote(bodyOnlyParams(P)).installment_months).toBe(0);
+ });
+});
+
+describe('선수금 합계는 여기서도 맞는다', () => {
+ it('선수금 + 할부원금 = 나눠 가질 몫', () => {
+ const r = v();
+ expect(r.down_payment + r.total_installment).toBe(
+ r.car_payment + r.body_payment - r.car_deposit - r.body_deposit,
+ );
+ });
+});
diff --git a/shared/pricing/vehicle-only.ts b/shared/pricing/vehicle-only.ts
new file mode 100644
index 0000000..a4d7550
--- /dev/null
+++ b/shared/pricing/vehicle-only.ts
@@ -0,0 +1,38 @@
+import type { QuoteParams } from './quote.js';
+
+/**
+ * **차량만 견적** — 특장을 얹지 않고 차량만 판다.
+ *
+ * [[bodyOnlyParams]] 의 거울상이다. 그쪽이 차량에 딸린 값을 0 으로 만들듯,
+ * 여기서는 **특장에 딸린 값을 0 으로** 만든다. 화면·백엔드가 각자 0 을 채우면
+ * 한쪽만 빠뜨렸을 때 금액이 조용히 어긋나므로 한 곳에 모은다.
+ *
+ * 무엇이 특장에 딸린 것인가 —
+ * · 특장 가격(탑·도어·격벽·온도기록계·스포일러) 얹지 않으니 없다
+ * · 프로모션(무상제공·금액할인) 특장 옵션에 붙는 할인이라 없다
+ * · 특장 계약금 특장을 안 만드니 걸 것이 없다
+ * · 특장 취득세(2%) 특장 금액이 0 이면 따라서 0 이지만, 입력도 지워 두 겹으로 막는다
+ * · 등록부가수수료 · 구조변경 비용 **구조변경 자체를 하지 않는다**
+ *
+ * **남는 것** — 차량가·탁송료·구매혜택·EV보조금·차량 등록비, 그리고 **할부(캐피탈)**.
+ * 차를 사는 거래라 캐피탈이 그대로 걸린다(특장만 견적과 정반대다).
+ *
+ * ⚠️ **계약서를 만들지 않는다.** 지금 계약서는 「특장 매매 및 구조변경 계약서」라
+ * 특장이 없는 거래에는 맞지 않는 문서다. 주문 전환도 하지 않는다 — 특장 제작이
+ * 없으니 특장사·구조변경·튜닝 단계가 통째로 빈다. 이 견적은 **견적서까지**다.
+ */
+export function vehicleOnlyParams(p: QuoteParams): QuoteParams {
+ return {
+ ...p,
+ vehicle_only: true,
+ // 특장 금액 — 옵션 합계와 트림 외 특장가를 전부 0 으로
+ body_price: 0,
+ promotion: 0,
+ body_deposit: 0,
+ // 특장이 없으면 특장 취득세도 없다(금액이 0 이라 자동으로 0 이지만 입력도 지운다)
+ special_acq_tax_rate: 0,
+ // 구조변경을 하지 않는다 — 등록부가수수료와 구조변경 비용이 함께 빠진다
+ etc_fee: 0,
+ structure_change_fee: 0,
+ };
+}
|