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
29 changes: 20 additions & 9 deletions backend/src/__tests__/quote-template.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,19 +41,30 @@ describe('견적서 양식 — 반복 블록과 렌더 코드가 짝이 맞는
expect(missing, `코드에만 있는 블록: ${missing.join(', ')}`).toEqual([]);
});

it('열마다 갈래가 짝을 이룬다 — 차량·특장·고객 셋 다', () => {
// 한 열만 갈라 두면 특장만 견적서에서 그 열만 옛 모양으로 남는다
for (const [a, b] of [['carSection', 'ownedSection'], ['topNormal', 'topOnly'], ['custNormal', 'custOnly']]) {
expect(inTemplate.has(a!), a).toBe(true);
expect(inTemplate.has(b!), b).toBe(true);
/** 열마다 어떤 갈래들이 있는지 — 견적 종류가 늘면 여기에 더한다. */
const COLUMNS = {
car: ['carSection', 'ownedSection'], // 차량 열: 파는 경우 / 고객 보유
top: ['topNormal', 'topOnly', 'topNone'], // 특장 열: 함께 / 특장만 / 특장 없음
cust: ['custNormal', 'custOnly'], // 고객 열: 할부 있음 / 없음
} as const;

it('열마다 갈래가 다 있다 — 하나라도 빠지면 그 열만 옛 모양으로 남는다', () => {
for (const [col, branches] of Object.entries(COLUMNS)) {
for (const b of branches) expect(inTemplate.has(b), `${col} 의 ${b}`).toBe(true);
}
});

it('갈래마다 정렬용 pad 가 하나씩 들어 있다', () => {
// pad 는 세 열의 마지막 줄을 같은 가로선에 맞춘다. 갈래에 없으면 그 열만 어긋난다.
for (const col of ['car', 'top', 'cust']) {
it('갈래마다 정렬용 pad 가 **하나씩** 들어 있다', () => {
/*
* pad 는 세 열의 마지막 줄을 같은 가로선에 맞춘다. 갈래에 없으면 그 열만 어긋난다.
*
* 개수를 숫자로 박아 두지 않는다 — 견적 종류가 늘 때마다 정당한 변경이 막히고,
* 통과시키려면 숫자를 고쳐 적게 되어 검사가 받아쓰기가 된다.
* **갈래 수와 같은지**를 본다.
*/
for (const [col, branches] of Object.entries(COLUMNS)) {
const n = (BODY.match(new RegExp(`<!-- pad:${col} -->`, 'g')) ?? []).length;
expect(n, `pad:${col}`).toBe(2); // 일반 갈래 1 + 특장만 갈래 1
expect(n, `pad:${col} (갈래 ${branches.length}개)`).toBe(branches.length);
}
});
});
93 changes: 93 additions & 0 deletions backend/src/__tests__/vehicle-only.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { folderQuoteKind } from '../services/customer-folders.js';

/**
* **차량만 견적** — 특장을 얹지 않고 차량만 판다. 특장만 견적의 거울상이다.
*
* 이 견적은 **견적서까지**다. 계약서(「특장 매매 및 구조변경 계약서」)는 특장이 없는
* 거래에 맞지 않고, 주문 전환도 하지 않는다 — 특장 제작이 없어 특장사·구조변경·튜닝
* 단계가 통째로 빈다.
*/
const ROOT = path.resolve(__dirname, '../../..');
const read = (rel: string) => readFileSync(path.join(ROOT, rel), 'utf8');

describe('견적 종류 판정', () => {
it('둘은 동시에 참일 수 없다 — 특장만이 이긴다', () => {
// 그러면 팔 것이 아무것도 남지 않는다. 먼저 있던 기능을 지켜 기존 견적을 보호한다.
expect(folderQuoteKind({ body_only: true, vehicle_only: true })).toBe('body');
});

it('각각을 알아본다', () => {
expect(folderQuoteKind({ body_only: true })).toBe('body');
expect(folderQuoteKind({ vehicle_only: true })).toBe('vehicle');
expect(folderQuoteKind({})).toBe('full');
expect(folderQuoteKind(null)).toBe('full');
});
});

describe('견적서 양식', () => {
const TPL = read('doc-templates/quote-template.html');
const PDF = read('backend/src/services/quote-pdf.ts');

it('특장 열에 「특장 없음」 갈래가 있다', () => {
expect(TPL).toContain('<!-- each:topNone -->');
expect(PDF).toContain("renderEach(html, 'topNone'");
});

it('🔴 차량만이면 특장 옵션 행을 그리지 않는다', () => {
// 0원 줄이 줄줄이 서면 「특장을 샀는데 공짜」로 읽힌다
expect(PDF).toMatch(/renderEach\(html, 'topOptions', vehicleOnly \? \[\] : topOptions\)/);
});

it('세 갈래가 서로 배타적이다', () => {
// topNormal 은 둘 중 어느 쪽도 아닐 때만 나온다
expect(PDF).toMatch(/renderEach\(html, 'topNormal', \(bodyOnly \|\| vehicleOnly\) \? \[\] : \[\{\}\]\)/);
});
});

describe('화면', () => {
const PANEL = read('frontend/src/components/OptionPanel.tsx');
const EXTRAS = read('frontend/src/components/QuoteExtras.tsx');

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

it('잠긴 탭에 머물러 있으면 차량 탭으로 되돌린다 — 빈 화면을 막는다', () => {
expect(PANEL).toMatch(/vehicleOnly && activeTab !== 'vehicle'\) setActiveTab\('vehicle'\)/);
});

it('🔴 차량만이면 프로모션이 잠긴다 — 특장 옵션에 붙는 할인이다', () => {
expect(EXTRAS).toMatch(/disabled=\{vehicleOnly\}/);
expect(EXTRAS).toMatch(/\{showPromo && !vehicleOnly &&/);
});

it('지방보조금 소진은 잠기지 않는다 — 차량 보조금이라 그대로 쓴다', () => {
const i = EXTRAS.indexOf('지방보조금 소진');
const around = EXTRAS.slice(Math.max(0, i - 700), i);
expect(around).not.toContain('vehicleOnly');
});

it('두 종류를 동시에 고를 수 없다', () => {
expect(PANEL).toMatch(/BodyOnlyToggle[\s\S]{0,120}disabled=\{!!vehicleOnly\}/);
expect(PANEL).toMatch(/VehicleOnlyToggle[\s\S]{0,120}disabled=\{!!bodyOnly\}/);
});
});

describe('목록에서 열어 보지 않고 가린다', () => {
it('영업 목록·관리자 목록·고객 서류함 셋 다 표시한다', () => {
expect(read('frontend/src/pages/SalesPage.tsx')).toContain('<QuoteKindTag quote={q} />');
expect(read('frontend/src/pages/AdminPage.tsx')).toContain('<QuoteKindTag quote={q} />');
expect(read('frontend/src/components/CustomerFolders.tsx')).toContain('KIND_LABEL[q.kind]');
});

it('일반 견적에는 아무것도 붙이지 않는다', () => {
// 대부분이 일반이라 다 붙이면 표가 배지로 뒤덮여 정작 다른 건이 묻힌다
expect(read('frontend/src/lib/quoteCustomer.ts')).toMatch(/full:\s*null/);
expect(read('frontend/src/components/CustomerFolders.tsx')).toMatch(/full:\s*null/);
});
});
5 changes: 4 additions & 1 deletion backend/src/routes/customer-folders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { prisma } from '../lib/prisma.js';
import { rbac, ownQuotesOnly, scopedToMine } from '../middleware/rbac.js';
import { VISIBLE } from '../lib/visibility.js';
import {
groupCustomers, collectDocs, resolveDocId, optionChips, groupDocsByQuote,
groupCustomers, collectDocs, resolveDocId, optionChips, groupDocsByQuote, folderQuoteKind,
type CustomerGroup, type FolderCustomer, type PinnedInput, type FolderQuote,
} from '../services/customer-folders.js';

Expand Down Expand Up @@ -137,6 +137,8 @@ customerFoldersRouter.get('/:key', rbac('ADMIN', 'SALES'), guard(async (req, res
id: true, quote_no: true, status: true, created_at: true, final_price: true,
selections: true, docs_frozen_at: true,
docs_frozen_quote_path: true, docs_frozen_contract_path: true,
// 견적 종류(특장만/차량만)를 카드에 표시하려면 필요하다
inputs: true,
},
orderBy: { id: 'desc' },
});
Expand Down Expand Up @@ -175,6 +177,7 @@ customerFoldersRouter.get('/:key', rbac('ADMIN', 'SALES'), guard(async (req, res
finalPrice: q.final_price,
options: optionChips((q.selections ?? {}) as Record<string, string>, c => nameOf.get(c)),
frozenAt: q.docs_frozen_at?.toISOString() ?? null,
kind: folderQuoteKind(q.inputs),
docs: byQuote.get(q.quote_no ?? null) ?? [],
}));

Expand Down
26 changes: 15 additions & 11 deletions backend/src/routes/quotes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ async function buildParams(
selections: Record<string, string>,
customer: CustomerInput | undefined,
calcYear: number,
extra?: { promotion_zeroed?: string[]; promotion_discount?: number; local_subsidy_off?: boolean; body_only?: boolean },
extra?: { promotion_zeroed?: string[]; promotion_discount?: number; local_subsidy_off?: boolean; body_only?: boolean; vehicle_only?: boolean },
): Promise<PricingParams> {
if (!prisma) throw new Error('DB_UNAVAILABLE');

Expand Down Expand Up @@ -245,17 +245,17 @@ quotesRouter.post('/calculate', rbac('SALES'), async (req: Request, res): Promis
res.status(503).json({ error: { code: 'DB_UNAVAILABLE', message: 'DB 연결 필요' } });
return;
}
const { model_code, year, selections, customer, promotion_zeroed, promotion_discount, local_subsidy_off, body_only } = req.body as {
const { model_code, year, selections, customer, promotion_zeroed, promotion_discount, local_subsidy_off, body_only, vehicle_only } = req.body as {
model_code?: string; year?: number;
selections?: Record<string, string>; customer?: CustomerInput;
promotion_zeroed?: string[]; promotion_discount?: number; local_subsidy_off?: boolean; body_only?: boolean;
promotion_zeroed?: string[]; promotion_discount?: number; local_subsidy_off?: boolean; body_only?: boolean; vehicle_only?: boolean;
};
if (!model_code || !selections) {
res.status(400).json({ error: { code: 'BAD_INPUT', message: 'model_code, selections 필수' } });
return;
}
try {
const params = await buildParams(model_code, selections, customer, year ?? new Date().getFullYear(), { promotion_zeroed, promotion_discount, local_subsidy_off, body_only });
const params = await buildParams(model_code, selections, customer, year ?? new Date().getFullYear(), { promotion_zeroed, promotion_discount, local_subsidy_off, body_only, vehicle_only });
const result = calcPrice(params);
if (result.status === 'unsupported') {
res.status(422).json({ error: { code: 'UNSUPPORTED', message: result.reason } });
Expand All @@ -275,10 +275,10 @@ quotesRouter.post('/calculate-total', rbac('SALES', 'ADMIN'), async (req: Reques
res.status(503).json({ error: { code: 'DB_UNAVAILABLE', message: 'DB 연결 필요' } });
return;
}
const { model_code, year, selections, customer, down_payment_rate, installment_months, promotion_zeroed, promotion_discount, local_subsidy_off, body_only } = req.body as {
const { model_code, year, selections, customer, down_payment_rate, installment_months, promotion_zeroed, promotion_discount, local_subsidy_off, body_only, vehicle_only } = req.body as {
model_code?: string; year?: number;
selections?: Record<string, string>; customer?: CustomerInput;
down_payment_rate?: number; installment_months?: number; promotion_zeroed?: string[]; promotion_discount?: number; local_subsidy_off?: boolean; body_only?: boolean;
down_payment_rate?: number; installment_months?: number; promotion_zeroed?: string[]; promotion_discount?: number; local_subsidy_off?: boolean; body_only?: boolean; vehicle_only?: boolean;
};
if (!model_code || !selections) {
res.status(400).json({ error: { code: 'BAD_INPUT', message: 'model_code, selections 필수' } });
Expand All @@ -287,7 +287,7 @@ quotesRouter.post('/calculate-total', rbac('SALES', 'ADMIN'), async (req: Reques
try {
const params = await buildQuoteParams(
model_code, selections, customer,
{ down_payment_rate, installment_months, promotion_zeroed, promotion_discount, local_subsidy_off, body_only },
{ down_payment_rate, installment_months, promotion_zeroed, promotion_discount, local_subsidy_off, body_only, vehicle_only },
year ?? new Date().getFullYear(),
);
res.json({ data: calcQuote(params) });
Expand Down Expand Up @@ -335,6 +335,7 @@ quotesRouter.get('/:id/total', rbac('SALES', 'ADMIN'), async (req: Request, res)
local_subsidy_off: inp['local_subsidy_off'] as boolean | undefined,
// 특장만 견적 — 빠뜨리면 다시 열 때 차량 금액이 되살아난다
body_only: inp['body_only'] === true,
vehicle_only: inp['vehicle_only'] === true,
},
quote.created_at.getFullYear(),
);
Expand Down Expand Up @@ -379,7 +380,7 @@ quotesRouter.patch('/:id/inputs', rbac('SALES', 'ADMIN'), requirePermission('quo
if (await isFrozen(id)) { res.status(409).json({ error: { code: 'DOCS_FROZEN', message: FROZEN_MESSAGE } }); return; }
// 허용 필드만 병합(입력시트 값). 임의 키 오염 방지.
const ALLOWED = ['down_payment_rate', 'down_payment_amount', 'installment_months', 'tax_exempt_type', 'has_biz_plate',
'biz_type', 'is_sosang', 'region', 'has_transport_license', 'diesel_conversion', 'diesel_status', 'promotion_zeroed', 'promotion_discount', 'memo', 'local_subsidy_off', 'body_only', 'vehicle_owned',
'biz_type', 'is_sosang', 'region', 'has_transport_license', 'diesel_conversion', 'diesel_status', 'promotion_zeroed', 'promotion_discount', 'memo', 'local_subsidy_off', 'body_only', 'vehicle_only', 'vehicle_owned',
// 매매계약서 전용 입력(견적서 생성 팝업에서 함께 받음). 전부 선택 — 비워두면 계약서에 공란으로 나간다.
'contract_party', 'buyer_agent', 'buyer_relation', 'buyer_regno', 'buyer_tel',
// 대표이사 — 법인 계약서 서명블록. 저장 후 사업자구분을 고칠 때 함께 고칠 수 있어야 한다.
Expand Down Expand Up @@ -640,12 +641,13 @@ quotesRouter.post('/', rbac('SALES'), requirePermission('quote.create'), async (
res.status(503).json({ error: { code: 'DB_UNAVAILABLE', message: 'DB 연결 필요' } });
return;
}
const { model_code, year, selections, customer, down_payment_rate, installment_months, promotion_zeroed, promotion_discount, memo, local_subsidy_off, body_only, vehicle_owned } = req.body as {
const { model_code, year, selections, customer, down_payment_rate, installment_months, promotion_zeroed, promotion_discount, memo, local_subsidy_off, body_only, vehicle_only, vehicle_owned } = req.body as {
model_code?: string; year?: number;
selections?: Record<string, string>; customer?: CustomerInput;
down_payment_rate?: number; installment_months?: number; promotion_zeroed?: string[]; promotion_discount?: number; memo?: string; local_subsidy_off?: boolean;
/** 특장만 견적 — 고객이 차를 이미 갖고 있다 */
body_only?: boolean;
vehicle_only?: boolean;
/** 특장만일 때 고객이 적어 주는 보유 차량 정보(견적서에 그대로 실린다) */
vehicle_owned?: Record<string, string>;
};
Expand All @@ -655,13 +657,13 @@ quotesRouter.post('/', rbac('SALES'), requirePermission('quote.create'), async (
}

const calcYear = year ?? new Date().getFullYear();
const params = await buildParams(model_code, selections, customer, calcYear, { promotion_zeroed, promotion_discount, local_subsidy_off, body_only });
const params = await buildParams(model_code, selections, customer, calcYear, { promotion_zeroed, promotion_discount, local_subsidy_off, body_only, vehicle_only });
const result = calcPrice(params);

// 저장되는 실구매가는 **총견적서 기준**(견적서 PDF·화면과 동일 규칙).
// calcPrice(Ver1.21)는 공급가액 산출과 하위호환 응답용으로만 유지한다.
const totalParams = await buildQuoteParams(model_code, selections, customer,
{ down_payment_rate, installment_months, promotion_zeroed, promotion_discount, local_subsidy_off, body_only }, calcYear);
{ down_payment_rate, installment_months, promotion_zeroed, promotion_discount, local_subsidy_off, body_only, vehicle_only }, calcYear);
const total = calcQuote(totalParams);

// 총견적서 입력시트 스냅샷(견적별 입력값 — 나중에 총견적서 재출력·재계산용)
Expand All @@ -683,6 +685,8 @@ 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원 처리한 특장옵션 그룹
promotion_discount: Math.max(0, Math.round(promotion_discount ?? 0)), // 프로모션 할인액(VAT 포함)
Expand Down
13 changes: 13 additions & 0 deletions backend/src/services/customer-folders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,9 +278,22 @@ export interface FolderQuote {
options: OptionChip[];
/** 서명 요청 때 굳힌 정본이 있는 건인가 */
frozenAt: string | null;
/**
* 견적 종류 — 서류함에서 **열어 보지 않고** 특장만·차량만을 가리게 한다.
* 금액만으로는 구분되지 않는다(특장만 2천만원과 차량만 3천만원이 나란히 있으면 알 길이 없다).
*/
kind: 'full' | 'body' | 'vehicle';
docs: FolderDoc[];
}

/** 저장된 입력에서 견적 종류를 읽는다. 둘은 동시에 참일 수 없다. */
export function folderQuoteKind(inputs: unknown): FolderQuote['kind'] {
const inp = (inputs ?? {}) as Record<string, unknown>;
if (inp['body_only'] === true) return 'body';
if (inp['vehicle_only'] === true) return 'vehicle';
return 'full';
}

/**
* 서류를 **견적별로 나눈다** — 화면이 견적 카드 하나에 그 건의 서류를 모아 보여 준다.
*
Expand Down
11 changes: 10 additions & 1 deletion backend/src/services/quote-calc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
assembleOptionSum, TAKBAE_RATE, DEFAULT_TAX_EXEMPT_TYPE,
dieselDeducts, toDieselStatus, type QuoteParams,
bodyOnlyParams,
vehicleOnlyParams,
} from '@buildup-ev/shared/pricing';

export type CustomerInput = {
Expand Down Expand Up @@ -60,6 +61,8 @@ export type QuoteExtraInput = {
* 차량에 딸린 입력을 통째로 0으로 만든다(shared 의 `bodyOnlyParams` 한 곳에서).
*/
body_only?: boolean;
/** 차량만 견적 — 특장을 얹지 않는다. body_only 와 동시에 참일 수 없다. */
vehicle_only?: boolean;
};

export async function buildQuoteParams(
Expand Down Expand Up @@ -151,5 +154,11 @@ export async function buildQuoteParams(
* 여기서 하나씩 0을 채우지 않고 shared 한 곳에 맡긴다 — 화면도 같은 함수를 쓰므로
* 견적서와 화면이 다른 값을 말할 수 없다.
*/
return extra?.body_only ? bodyOnlyParams(params) : params;
/*
* 둘은 **동시에 참일 수 없다** — 그러면 팔 것이 아무것도 남지 않는다.
* 어쩌다 둘 다 들어오면 특장만을 우선한다(먼저 있던 기능이라 기존 견적을 지킨다).
*/
if (extra?.body_only) return bodyOnlyParams(params);
if (extra?.vehicle_only) return vehicleOnlyParams(params);
return params;
}
Loading