Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions backend/src/__tests__/vehicle-only.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import path from 'node:path';
import { folderQuoteKind } from '../services/customer-folders.js';

/**
* **차량만 견적** — 특장을 얹지 않고 차량만 판다. 특장만 견적의 거울상이다.
* **차량만 견적** — 특장을 장착하지 않고 차량만 판다. 특장만 견적의 거울상이다.
*
* 이 견적은 **견적서까지**다. 계약서(「특장 매매 및 구조변경 계약서」)는 특장이 없는
* 거래에 맞지 않고, 주문 전환도 하지 않는다 — 특장 제작이 없어 특장사·구조변경·튜닝
Expand Down Expand Up @@ -53,7 +53,8 @@ describe('화면', () => {

it('🔴 차량만이면 특장·옵션 탭을 누를 수 없다', () => {
// 고를 수 있게 두면 고른 것이 금액에 안 잡혀 「왜 반영이 안 되냐」가 된다
expect(PANEL).toMatch(/if \(vehicleOnly && tab\.key !== 'vehicle'\) return/);
// 잠금 판정은 lockedTab 한 곳에 있다 — 탭 표시와 저장 판정이 갈리지 않게
expect(PANEL).toMatch(/if \(lockedTab\(tab\.key\)\) return/);
expect(PANEL).toContain('styles.tabOff');
});

Expand Down Expand Up @@ -91,3 +92,40 @@ describe('목록에서 열어 보지 않고 가린다', () => {
expect(read('frontend/src/components/CustomerFolders.tsx')).toMatch(/full:\s*null/);
});
});

describe('차량만 견적도 저장할 수 있어야 한다', () => {
const PANEL = read('frontend/src/components/OptionPanel.tsx');

it('🔴 잠긴 탭을 「확인 필요」로 세지 않는다', () => {
/*
* 「방문하지 않은 탭」을 그대로 세면, 차량만 견적에서는 특장·옵션 탭에 **갈 수가 없어서**
* 확인이 영영 끝나지 않는다 — 저장 버튼이 「특장·옵션 확인 필요」로 잠긴 채 남는다(실제 제보).
*/
expect(PANEL).toMatch(/const unseen = TABS\.filter\(\(t\) => !visited\.has\(t\.key\) && !lockedTab\(t\.key\)\)/);
});

it('잠금 판정을 한 곳에서 한다 — 탭 표시와 저장 판정이 갈리면 안 된다', () => {
expect(PANEL).toMatch(/const lockedTab = /);
// 조건을 손으로 다시 쓴 곳이 없어야 한다
expect(PANEL).not.toMatch(/vehicleOnly && tab\.key !== 'vehicle'/);
});

it('차량 트림 탭에 안내 박스를 끼워 넣지 않는다 — 레이아웃이 밀린다', () => {
// 잠그기만 하면 될 일에 칸을 더하면 탭 높이가 달라져 화면이 흔들린다
expect(PANEL).not.toContain('VehicleOnlyNotice');
expect(read('frontend/src/components/BodyOnlyPanel.tsx')).not.toContain('VehicleOnlyNotice');
});
});

describe('용어', () => {
it('🔴 특장은 「얹는다」가 아니라 「장착한다」', () => {
// 「얹다」는 전문적이지 않다(실제 지적). 특장 문맥에서는 쓰지 않는다.
const FILES = [
'shared/pricing/vehicle-only.ts', 'shared/pricing/body-only.ts', 'shared/pricing/quote.ts',
'frontend/src/components/BodyOnlyPanel.tsx', 'frontend/src/components/OptionPanel.tsx',
'doc-templates/quote-template.html',
];
const bad = FILES.filter(f => /특장[^\n]{0,40}얹|얹[^\n]{0,20}특장/.test(read(f)));
expect(bad, `「얹다」가 남은 곳: ${bad.join(', ')}`).toEqual([]);
});
});
2 changes: 1 addition & 1 deletion backend/src/routes/public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ publicRouter.post('/inquiries', submitLimiter, async (req: Request, res: Respons
// 특장만 견적 — 영업이 이어받을 때 같은 금액이 나와야 한다
body_only: body.body_only === true,
/*
* 어떤 차에 얹는지 — 고객이 고른 값 그대로 남긴다.
* 어떤 차에 장착하는지 — 고객이 고른 값 그대로 남긴다.
* 영업이 이어받아 견적을 열었을 때 여기서 되읽는다(다시 물어보지 않게).
*/
vehicle_owned: body.body_only === true
Expand Down
2 changes: 1 addition & 1 deletion backend/src/routes/quotes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -685,7 +685,7 @@ quotesRouter.post('/', rbac('SALES'), requirePermission('quote.create'), async (
* 보유 차량 정보는 고객이 적어 준 값 그대로(우리가 아는 제원이 아니다).
*/
body_only: body_only === true,
// 차량만 견적 — 특장을 얹지 않는다. 특장만과 동시에 참일 수 없다.
// 차량만 견적 — 특장을 장착하지 않는다. 특장만과 동시에 참일 수 없다.
vehicle_only: body_only !== true && vehicle_only === true,
vehicle_owned: body_only === true ? (vehicle_owned ?? {}) : {},
promotion_zeroed: promotion_zeroed ?? [], // 프로모션: 0원 처리한 특장옵션 그룹
Expand Down
4 changes: 2 additions & 2 deletions backend/src/services/quote-calc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,11 @@ export type QuoteExtraInput = {
promotion_discount?: number;
local_subsidy_off?: boolean; // 견적별 지방보조금 미적용(영업 토글)
/**
* **특장만 견적** — 고객이 차를 이미 갖고 있어 특장만 얹는다.
* **특장만 견적** — 고객이 차를 이미 갖고 있어 특장만 장착한다.
* 차량에 딸린 입력을 통째로 0으로 만든다(shared 의 `bodyOnlyParams` 한 곳에서).
*/
body_only?: boolean;
/** 차량만 견적 — 특장을 얹지 않는다. body_only 와 동시에 참일 수 없다. */
/** 차량만 견적 — 특장을 장착하지 않는다. body_only 와 동시에 참일 수 없다. */
vehicle_only?: boolean;
};

Expand Down
2 changes: 1 addition & 1 deletion backend/src/services/quote-pdf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ export async function generateQuotePdf(quoteId: number): Promise<QuotePdfResult>


const bodyOnly = inp['body_only'] === true;
/** 차량만 견적 — 특장을 얹지 않는다. 특장만과 동시에 참일 수 없다. */
/** 차량만 견적 — 특장을 장착하지 않는다. 특장만과 동시에 참일 수 없다. */
const vehicleOnly = !bodyOnly && inp['vehicle_only'] === true;

const data = {
Expand Down
2 changes: 1 addition & 1 deletion doc-templates/quote-template.html
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ <h2 class="sec">특장 정보</h2>
<!-- /each:topOnly -->

<!--
**차량만 견적** — 특장을 얹지 않는다. 금액을 0원으로 줄줄이 적으면 「특장을 샀는데
**차량만 견적** — 특장을 장착하지 않는다. 금액을 0원으로 줄줄이 적으면 「특장을 샀는데
공짜」로 읽히므로, 칸을 비우고 **왜 비었는지 한 줄로** 적는다.
(특장만 견적에서 차량 열을 `ownedSection` 으로 비우는 것과 같은 방식이다)
-->
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/api/quotes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export interface SaveQuoteRequest extends Partial<QuotePriceExtras> {
memo?: string // 메모/안내문
/** 특장만 견적 — 고객이 차를 이미 갖고 있다(차량 금액·보조금이 전부 빠진다) */
body_only?: boolean
/** 차량만 견적 — 특장을 얹지 않는다 */
/** 차량만 견적 — 특장을 장착하지 않는다 */
vehicle_only?: boolean
/** 특장만일 때 고객이 적어 주는 보유 차량 정보 */
vehicle_owned?: Record<string, string>
Expand Down
41 changes: 4 additions & 37 deletions frontend/src/components/BodyOnlyPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { BTN } from '../styles/buttons'

/**
* **특장만 견적** — 고객이 차를 이미 갖고 있어 특장만 얹는다.
* **특장만 견적** — 고객이 차를 이미 갖고 있어 특장만 장착한다.
*
* 차량을 우리가 팔지 않으므로 차량가·탁송료·구매혜택·EV보조금·차량 등록비가 전부 빠진다.
* 보유 차량의 **차종**은 계약서 생성 단계에서 받는다 — 견적 단계에서는 금액만 보면 되고,
Expand All @@ -28,7 +28,7 @@ export const OWNED_MODELS = [
* 냉동기는 주행 중에도 **차량 전원**으로 돌아간다 — V2L 포트가 없으면 설치 자체가 불가능하다.
*
* 우리가 파는 차(기본·플러스)는 모두 V2L 이 있어 확인할 것이 없다.
* **고객 차에 얹을 때만** 물어야 한다.
* **고객 차에 장착할 때만** 물어야 한다.
*/
export const V2L_NOTICE = '냉동 사양은 차량에서 전원을 받습니다 — 고객 차량에 사용 가능한 V2L 포트가 있어야 설치할 수 있습니다.'
export const V2L_CONFIRM = 'V2L 모듈이 있어야 냉동기 설치가 가능합니다. 고객 차량에 사용 가능한 V2L 포트가 있음을 확인했습니다.'
Expand All @@ -55,7 +55,7 @@ export function BodyOnlyToggle({ on, onToggle, disabled }: {
}

/**
* **차량만 견적** — 특장을 얹지 않고 차량만 판다. [[BodyOnlyToggle]] 의 거울상이다.
* **차량만 견적** — 특장을 장착하지 않고 차량만 판다. [[BodyOnlyToggle]] 의 거울상이다.
*
* 특장만과 **동시에 고를 수 없다.** 둘 다 켜면 팔 것이 아무것도 남지 않는다 —
* 한쪽을 켜면 다른 쪽 버튼이 눌리지 않게 막는다.
Expand All @@ -75,47 +75,14 @@ export function VehicleOnlyToggle({ on, onToggle, disabled }: {
)
}

/**
* 차량만을 골랐을 때만 뜨는 안내 — 무엇이 빠지는지 그 자리에서 알린다.
* 견적서를 뽑고 나서 「특장이 왜 없냐」를 묻게 두면 안 된다.
*/
export function VehicleOnlyNotice() {
return (
<div style={vo.box}>
<div style={vo.title}>차량만 판매하는 견적입니다</div>
<div style={vo.body}>
특장을 얹지 않아 <b>특장·옵션 탭과 프로모션이 잠깁니다.</b>
구조변경이 없어 특장 취득세·등록부가수수료·구조변경 비용도 빠집니다.
</div>
<div style={vo.body}>
보조금과 할부(캐피탈)는 <b>그대로 적용됩니다</b> — 차를 사는 거래이기 때문입니다.
</div>
<div style={vo.warn}>
이 견적은 <b>견적서까지</b>입니다. 계약서·주문 전환은 특장 매매 계약이라 해당하지 않습니다.
</div>
</div>
)
}

const vo: Record<string, React.CSSProperties> = {
box: {
marginTop: 'var(--sp-4)', padding: '12px 14px',
border: '0.5px solid var(--line)', borderRadius: 8, background: 'var(--card)',
display: 'flex', flexDirection: 'column', gap: 6,
},
title: { fontSize: 13, fontWeight: 700, color: 'var(--dark)' },
body: { fontSize: 12, color: 'var(--muted)', lineHeight: 1.6 },
warn: { fontSize: 12, color: 'var(--dark)', lineHeight: 1.6, marginTop: 2 },
}

/**
* 특장만을 **골랐을 때만** 뜨는 안내. 평소에는 보이지 않는다 —
* 차량을 사는 대부분의 견적에는 해당 없는 이야기라, 늘 띄워 두면 읽지 않게 된다.
*
* 여기서는 **알리기만** 한다. 실제 확인(체크)은 특장 탭에서 냉동을 고르는 그 자리에서 받는다.
*/
export function BodyOnlyNotice({ model, onModelChange }: {
/** 보유 차종 — **임시저장 단계**에서 받는다. 어떤 차에 얹는지는 견적의 전제다. */
/** 보유 차종 — **임시저장 단계**에서 받는다. 어떤 차에 장착하는지는 견적의 전제다. */
model: string
/**
* 없으면 **안내만** 하고 보유 차종은 묻지 않는다 — 공개 창구가 그렇다.
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/InquiryModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ export function InquiryModal({ modelCode, selections, subsidy, onSubsidyChange,
[filled(name), '성명'],
[phone.replace(/\D/g, '').length >= 10, '휴대폰'],
[/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email.trim()), '이메일'],
// 특장만이면 어떤 차에 얹는지가 견적의 전제다 — 「차량 트림」 화면에서 고른다
// 특장만이면 어떤 차에 장착하는지가 견적의 전제다 — 「차량 트림」 화면에서 고른다
[!bodyOnly || !!ownedModel, '보유 차종'],
/*
* 보조금 조건은 **전부** 필수 — 하나라도 비면 화면에 보인 실구매가가 달라진다.
Expand Down
24 changes: 15 additions & 9 deletions frontend/src/components/OptionPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { ApiPricingBundle } from '@shared/types/index'
import type { PricingOk } from '@shared/pricing/core'
import { VehicleOptionsTab } from './tabs/VehicleOptionsTab'
import { BodyOptionsTab } from './tabs/BodyOptionsTab'
import { BodyOnlyToggle, BodyOnlyNotice, VehicleOnlyToggle, VehicleOnlyNotice } from './BodyOnlyPanel'
import { BodyOnlyToggle, BodyOnlyNotice, VehicleOnlyToggle } from './BodyOnlyPanel'
import { InteriorOptionsTab } from './tabs/InteriorOptionsTab'
import { groupsByCategory, OPTION_CATEGORY } from '../lib/optionRules'
import { QuoteExtras } from './QuoteExtras'
Expand Down Expand Up @@ -36,7 +36,7 @@ interface Props {
* 공개 화면에는 주지 않는다(영업이 판단할 성격의 견적이다).
*/
bodyOnly?: boolean
/** 차량만 견적 — 특장을 얹지 않는다. 특장·옵션 탭이 잠긴다. */
/** 차량만 견적 — 특장을 장착하지 않는다. 특장·옵션 탭이 잠긴다. */
vehicleOnly?: boolean
onToggleVehicleOnly?: (v: boolean) => void
onToggleBodyOnly?: (v: boolean) => void
Expand Down Expand Up @@ -116,7 +116,14 @@ export function OptionPanel({
}, [vehicleOnly, activeTab])
// 견적 저장 전에 모든 단계를 확인하게 강제한다. 기본 화면인 트림(vehicle)은 이미 본 것으로 친다.
const [visited, setVisited] = useState<Set<TabKey>>(new Set<TabKey>(['vehicle']))
const unseen = TABS.filter((t) => !visited.has(t.key))
/*
* ⚠️ **잠긴 탭은 확인 대상이 아니다.**
*
* 차량만 견적이면 특장·옵션 탭을 누를 수 없다. 그런데 「방문하지 않은 탭」을 그대로
* 세면 **갈 수도 없는 곳을 확인하라며 저장이 영영 막힌다**(실제 제보).
*/
const lockedTab = (k: TabKey) => !!vehicleOnly && k !== 'vehicle'
const unseen = TABS.filter((t) => !visited.has(t.key) && !lockedTab(t.key))
const btnLabel = isSaving
? '저장 중…'
: saveLabel && !unseen.length && !isUnsupported
Expand All @@ -136,19 +143,19 @@ export function OptionPanel({
<aside style={{ ...styles.panel, ...(compact ? styles.panelCompact : null) }}>
<div style={styles.tabs}>
{/*
차량만 견적이면 **특장·옵션 탭을 잠근다.** 특장을 얹지 않는데 고를 수 있게 두면
차량만 견적이면 **특장·옵션 탭을 잠근다.** 특장을 장착하지 않는데 고를 수 있게 두면
고른 것이 금액에 안 잡혀 「왜 반영이 안 되냐」가 된다. 아예 못 누르게 막는다.
*/}
{TABS.map(tab => (
<div
key={tab.key}
// 탭은 <div> 라 disabled 가 없다 — 눌러도 아무 일이 없게 만들고 모양으로 알린다
style={tab.key === activeTab ? styles.tabOn
: (vehicleOnly && tab.key !== 'vehicle') ? styles.tabOff : styles.tab}
aria-disabled={vehicleOnly && tab.key !== 'vehicle'}
title={(vehicleOnly && tab.key !== 'vehicle') ? '차량만 견적이라 특장 옵션을 고르지 않습니다' : undefined}
: lockedTab(tab.key) ? styles.tabOff : styles.tab}
aria-disabled={lockedTab(tab.key)}
title={lockedTab(tab.key) ? '차량만 견적이라 특장 옵션을 고르지 않습니다' : undefined}
onClick={() => {
if (vehicleOnly && tab.key !== 'vehicle') return
if (lockedTab(tab.key)) return
setActiveTab(tab.key); setVisited((v) => new Set(v).add(tab.key))
}}
>
Expand Down Expand Up @@ -186,7 +193,6 @@ export function OptionPanel({
{bodyOnly && (
<BodyOnlyNotice model={ownedModel ?? ''} onModelChange={onOwnedModelChange} />
)}
{vehicleOnly && <VehicleOnlyNotice />}
</>
)}
{activeTab === 'body' && (
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/QuoteExtras.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ interface Props {
localSubsidyOff: boolean
/**
* 차량만 견적 — **프로모션이 잠긴다.**
* 프로모션은 특장 옵션에 붙는 할인이라, 특장을 안 얹으면 깎을 것이 없다.
* 프로모션은 특장 옵션에 붙는 할인이라, 특장을 장착하지 않으면 깎을 것이 없다.
* (지방보조금 소진은 차량 보조금이라 그대로 쓴다)
*/
vehicleOnly?: boolean
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/lib/liveQuote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export interface LiveTotalArgs {
localSubsidyOff?: boolean
/** 특장만 견적 — 고객이 차를 이미 갖고 있어 차량 금액·보조금이 전부 빠진다 */
bodyOnly?: boolean
/** 차량만 견적 — 특장을 얹지 않는다. 특장만과 동시에 참일 수 없다. */
/** 차량만 견적 — 특장을 장착하지 않는다. 특장만과 동시에 참일 수 없다. */
vehicleOnly?: boolean
/** 영업 화면의 저장된 고객(영업용 번호판·면세구분). 공개 화면은 없음 */
customer?: Pick<CustomerInfo, 'has_biz_plate' | 'tax_exempt_type'> | null
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/lib/quoteCustomer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export function isBodyOnly(q: ApiQuote): boolean {
return ((q.inputs ?? {}) as Record<string, unknown>)['body_only'] === true
}

/** 차량만 견적 — 특장을 얹지 않고 차량만 판다. 특장만과 동시에 참일 수 없다. */
/** 차량만 견적 — 특장을 장착하지 않고 차량만 판다. 특장만과 동시에 참일 수 없다. */
export function isVehicleOnly(q: ApiQuote): boolean {
const inp = (q.inputs ?? {}) as Record<string, unknown>
return inp['body_only'] !== true && inp['vehicle_only'] === true
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/pages/PublicConfiguratorPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export function PublicConfiguratorPage() {
const [bodyOnly, setBodyOnly] = useState(false)
/*
* 보유 차종 — **공개 창구에서도 고르게 한다.**
* 어떤 차에 얹는지는 견적의 전제라, 영업이 나중에 물어보려면 통화를 한 번 더 해야 한다.
* 어떤 차에 장착하는지는 견적의 전제라, 영업이 나중에 물어보려면 통화를 한 번 더 해야 한다.
* 4택 드롭다운이라 묻는 값이 늘어도 신청이 길어지지 않는다.
*/
const [ownedModel, setOwnedModel] = useState('')
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/pages/SalesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -688,7 +688,7 @@ export function SalesPage() {
*/
const [bodyOnly, setBodyOnly] = useState(false)
/**
* 차량만 견적 — 특장을 얹지 않고 차량만 판다. 특장만의 거울상이다.
* 차량만 견적 — 특장을 장착하지 않고 차량만 판다. 특장만의 거울상이다.
* 둘은 동시에 참일 수 없다(그러면 팔 것이 없다).
*/
const [vehicleOnly, setVehicleOnly] = useState(false)
Expand Down Expand Up @@ -838,7 +838,7 @@ export function SalesPage() {
if (!bundle) return
if (liveCalc?.status === 'unsupported') return
/*
* 냉동은 차량 전원으로 냉동기를 돌린다. 고객 차에 얹는 경우 V2L 이 없으면
* 냉동은 차량 전원으로 냉동기를 돌린다. 고객 차에 장착하는 경우 V2L 이 없으면
* **설치 자체가 불가능하다** — 확인 없이 견적이 나가면 되돌릴 수 없다.
*/
if (bodyOnly && selections['BODYTYPE'] === 'BODY_REEFER' && !v2lConfirmed) {
Expand Down
2 changes: 1 addition & 1 deletion shared/pricing/body-only.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { bodyOnlyParams } from './body-only.js';
import { QUOTE_PARAMS } from './fixtures.js';

/**
* **특장만 견적** — 차를 이미 가진 고객에게 특장만 얹어 파는 경우.
* **특장만 견적** — 차를 이미 가진 고객에게 특장만 장착해 파는 경우.
*
* 여기서 지키는 것은 하나다: **차량에 딸린 금액이 하나도 남지 않되, 특장 쪽은 그대로일 것.**
* 한 줄이라도 새어 나오면 고객이 사지도 않은 차의 세금이나 보조금이 견적서에 찍힌다.
Expand Down
2 changes: 1 addition & 1 deletion shared/pricing/body-only.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { QuoteParams } from './quote.js';

/**
* **특장만 견적** — 고객이 차를 이미 갖고 있어 특장만 얹는다.
* **특장만 견적** — 고객이 차를 이미 갖고 있어 특장만 장착한다.
*
* 차량에 딸린 입력을 **한 곳에서** 0으로 만든다. 화면·백엔드가 각자 0을 채우면
* 한쪽만 빠뜨렸을 때 금액이 조용히 어긋난다(견적서와 화면이 다른 값을 말하게 된다).
Expand Down
Loading