diff --git a/backend/src/routes/quotes.ts b/backend/src/routes/quotes.ts index 46df4bc..41bd672 100644 --- a/backend/src/routes/quotes.ts +++ b/backend/src/routes/quotes.ts @@ -328,6 +328,7 @@ quotesRouter.get('/:id/total', rbac('SALES', 'ADMIN'), async (req: Request, res) quote.model_code, (quote.selections ?? {}) as Record, customer, { down_payment_rate: inp['down_payment_rate'] as number | undefined, + down_payment_amount: inp['down_payment_amount'] as number | undefined, installment_months: inp['installment_months'] as number | undefined, promotion_zeroed: inp['promotion_zeroed'] as string[] | undefined, promotion_discount: inp['promotion_discount'] as number | undefined, @@ -377,7 +378,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', 'installment_months', 'tax_exempt_type', 'has_biz_plate', + 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', // 매매계약서 전용 입력(견적서 생성 팝업에서 함께 받음). 전부 선택 — 비워두면 계약서에 공란으로 나간다. 'contract_party', 'buyer_agent', 'buyer_relation', 'buyer_regno', 'buyer_tel', @@ -411,6 +412,7 @@ quotesRouter.patch('/:id/inputs', rbac('SALES', 'ADMIN'), requirePermission('quo }, { down_payment_rate: merged['down_payment_rate'] as number | undefined, + down_payment_amount: merged['down_payment_amount'] as number | undefined, installment_months: merged['installment_months'] as number | undefined, promotion_zeroed: merged['promotion_zeroed'] as string[] | undefined, promotion_discount: merged['promotion_discount'] as number | undefined, @@ -475,6 +477,7 @@ quotesRouter.patch('/:id/selections', rbac('SALES', 'ADMIN'), requirePermission( }, { down_payment_rate: inp['down_payment_rate'] as number | undefined, + down_payment_amount: inp['down_payment_amount'] as number | undefined, installment_months: inp['installment_months'] as number | undefined, promotion_zeroed: inp['promotion_zeroed'] as string[] | undefined, local_subsidy_off: inp['local_subsidy_off'] as boolean | undefined, diff --git a/backend/src/services/contract-docgen.ts b/backend/src/services/contract-docgen.ts index e7d57c2..0a76a91 100644 --- a/backend/src/services/contract-docgen.ts +++ b/backend/src/services/contract-docgen.ts @@ -238,6 +238,7 @@ export async function buildContractTokensFromQuote(quoteId: number): Promise }; const params = await buildQuoteParams(quote.model_code, selections, customer, { down_payment_rate: inp['down_payment_rate'] as number | undefined, + down_payment_amount: inp['down_payment_amount'] as number | undefined, installment_months: inp['installment_months'] as number | undefined, promotion_zeroed: inp['promotion_zeroed'] as string[] | undefined, promotion_discount: inp['promotion_discount'] as number | undefined, @@ -178,7 +179,17 @@ export async function generateQuotePdf(quoteId: number): Promise const trimName = valueName.get(selections['TRIM'] ?? '') ?? ''; const bizType = (inp['biz_type'] as string | undefined) ?? 'individual'; - const downRate = (inp['down_payment_rate'] as number | undefined) ?? 0; + /* + * 견적서에 적는 선수금 비율 — **계산이 실제로 적용한 비율**을 쓴다. + * + * 금액으로 정한 견적은 저장된 비율이 그 금액과 다르다(비율은 안 건드리고 금액만 넣으므로). + * 저장값을 그대로 적으면 「선수금 30% / 1,200만원」처럼 서로 안 맞는 두 줄이 나간다. + * 계산이 돌려주는 `down_payment_ratio` 는 절삭한 선수금에서 되돌려 구한 값이라 항상 맞는다. + * + * ⚠️ **버림으로 적는다.** 반올림하면 29.6%가 30%로 적혀, 옆 금액과 맞춰 보는 사람이 + * 「30%인데 왜 이 금액이냐」고 묻게 된다. + */ + const downRate = r.down_payment_ratio; const optionSummary = [ `탑 종류 = ${bodyDisp}`, `탑 높이 = ${topDisp}`, `스포일러 = ${ox(spoilerOn)}`, @@ -232,7 +243,8 @@ export async function generateQuotePdf(quoteId: number): Promise isSosang: ox(!!inp['is_sosang']), hasTransportLicense: ox(!!inp['has_transport_license']), // 엑셀 '입력 시트' C5 선택지 문구 그대로(경유차없음 / 유지 / 폐차). 옛 견적은 boolean 으로 복원. dieselStatus: DIESEL_STATUS_LABEL[toDieselStatus(inp['diesel_status'], inp['diesel_conversion'])], - hasCommercialPlate: ox(!!inp['has_biz_plate']), advanceRate: `${Math.round(downRate * 100)}%`, + hasCommercialPlate: ox(!!inp['has_biz_plate']), advanceRate: `${Math.floor(downRate * 100)}%`, + advanceAmount: won(r.down_payment), // 특장 계약금 — 기준데이터(body_deposit)에서 온다. 양식에 숫자를 박지 않는다 deposit: won(r.body_deposit), vatRefundPrice: won(r.vat_refund_price), diff --git a/doc-templates/quote-template.html b/doc-templates/quote-template.html index 93b93ae..c3b8b66 100644 --- a/doc-templates/quote-template.html +++ b/doc-templates/quote-template.html @@ -198,7 +198,13 @@

고객 정보

소상공인{{ cust.isSosang }} 화물자동차 운송사업허가증{{ cust.hasTransportLicense }} 경유차 폐차여부{{ cust.dieselStatus }} - 선수금{{ cust.advanceRate }} + + 선수금 (비율){{ cust.advanceRate }} + 선수금 (금액){{ cust.advanceAmount }} 할부원금{{ inst.total }} 이율{{ inst.interestRate }} 할부이자{{ inst.interest }} diff --git a/frontend/src/components/ConfirmQuoteModal.tsx b/frontend/src/components/ConfirmQuoteModal.tsx index 4d3f0f7..c201a37 100644 --- a/frontend/src/components/ConfirmQuoteModal.tsx +++ b/frontend/src/components/ConfirmQuoteModal.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' +import { DownPaymentFields } from './DownPaymentFields' import { BTN } from '../styles/buttons' -import { fetchInstallmentRates, saveQuoteInputs, confirmQuote, type InstallmentRateOption } from '../api/quotes' +import { fetchInstallmentRates, saveQuoteInputs, confirmQuote, fetchTotalQuote, type InstallmentRateOption } from '../api/quotes' /** * 견적서 생성 팝업 — **캐피탈 관련 입력만** 받는다 (선수금 비율 · 할부 개월수). @@ -36,7 +37,12 @@ export function ConfirmQuoteModal({ quoteId, customerName, status, initialInputs const init = initialInputs ?? {} const isConfirmed = status !== 'draft' - const [downPct, setDownPct] = useState(String(((init['down_payment_rate'] as number) ?? 0.3) * 100)) + const [down, setDown] = useState<{ rate: number; amount?: number }>({ + rate: (init['down_payment_rate'] as number) ?? 0.3, + ...(init['down_payment_amount'] !== undefined ? { amount: init['down_payment_amount'] as number } : {}), + }) + /** 비율↔금액을 서로 바꿔 보여 줄 기준 금액 — 서버 계산이 준 값을 쓴다 */ + const [base, setBase] = useState(0) const [months, setMonths] = useState((init['installment_months'] as number) ?? 0) const [rates, setRates] = useState([]) @@ -44,11 +50,18 @@ export function ConfirmQuoteModal({ quoteId, customerName, status, initialInputs const [err, setErr] = useState('') useEffect(() => { fetchInstallmentRates().then(setRates).catch(() => {}) }, []) + // 특장만이면 캐피탈이 없어 선수금 자체를 묻지 않는다 — 부를 이유도 없다 + useEffect(() => { + if (bodyOnly) return + fetchTotalQuote(quoteId).then(t => setBase(t.total.down_payment_base ?? 0)).catch(() => {}) + }, [quoteId, bodyOnly]) function payload() { return { // 특장만이면 캐피탈이 없다 — 0으로 눌러 저장한다(shared 의 bodyOnlyParams 와 같은 값) - down_payment_rate: bodyOnly ? 0 : (Number(downPct) || 0) / 100, + down_payment_rate: bodyOnly ? 0 : down.rate, + // 금액으로 정했을 때만 실어 보낸다 — 비율로 바꾸면 지워야 기준이 되돌아온다 + down_payment_amount: bodyOnly ? null : (down.amount ?? null), installment_months: bodyOnly ? 0 : months, /* * ⚠️ `tax_exempt_type`·`has_biz_plate` 는 **보내지 않는다.** @@ -89,9 +102,17 @@ export function ConfirmQuoteModal({ quoteId, customerName, status, initialInputs ) : ( <> - - setDownPct(e.target.value)} /> + ( + <> + + {children} + + )} + inputStyle={s.input} + /> editRate(e.target.value)} + /> + + + editAmount(e.target.value)} + /> + + {mode !== null && !disabled && ( +
+ {mode === 'rate' ? '비율' : '금액'}로 정했습니다 — 다른 쪽은 자동으로 계산됩니다. + 바꾸려면 적어 둔 칸을 비우세요. +
+ )} + + ) +} + +const hint: React.CSSProperties = { + gridColumn: '1 / -1', fontSize: 'var(--fs-caption)', color: 'var(--muted)', marginTop: -4, +} diff --git a/frontend/src/components/QuoteEditModal.tsx b/frontend/src/components/QuoteEditModal.tsx index 83afda8..5a11e2a 100644 --- a/frontend/src/components/QuoteEditModal.tsx +++ b/frontend/src/components/QuoteEditModal.tsx @@ -1,8 +1,9 @@ import { useEffect, useMemo, useState } from 'react' +import { DownPaymentFields } from './DownPaymentFields' import type { ApiPricingBundle, ApiQuote } from '@shared/types/index' import { fetchPricingBundle } from '../api/models' import { - saveQuoteInputs, saveQuoteSelections, saveQuoteCustomer, fetchInstallmentRates, + saveQuoteInputs, saveQuoteSelections, saveQuoteCustomer, fetchInstallmentRates, fetchTotalQuote, fetchQuoteHistory, type InstallmentRateOption, type QuoteChange, } from '../api/quotes' import { computeDisabledGroups, computeHidden, sanitizeSelections, groupsByCategory, OPTION_CATEGORY } from '../lib/optionRules' @@ -302,17 +303,27 @@ function CustomerTab({ quote, frozen, busy, setBusy, onDone, onFail }: SubProps) // ── 할부 탭 ──────────────────────────────────────────────────────────────── function InputsTab({ quote, frozen, busy, setBusy, onDone, onFail }: SubProps) { const inp = (quote.inputs ?? {}) as Record - const [downPct, setDownPct] = useState(String(((inp['down_payment_rate'] as number) ?? 0.3) * 100)) + const [down, setDown] = useState<{ rate: number; amount?: number }>({ + rate: (inp['down_payment_rate'] as number) ?? 0.3, + ...(inp['down_payment_amount'] !== undefined ? { amount: inp['down_payment_amount'] as number } : {}), + }) const [months, setMonths] = useState((inp['installment_months'] as number) ?? 0) const [rates, setRates] = useState([]) + /** 비율↔금액을 서로 바꿔 보여 줄 기준 금액 — 서버 계산이 준 값을 쓴다 */ + const [base, setBase] = useState(0) useEffect(() => { fetchInstallmentRates().then(setRates).catch(() => {}) }, []) + useEffect(() => { + fetchTotalQuote(quote.id).then(t => setBase(t.total.down_payment_base ?? 0)).catch(() => {}) + }, [quote.id]) async function save() { setBusy(true) try { await saveQuoteInputs(quote.id, { - down_payment_rate: (Number(downPct) || 0) / 100, + down_payment_rate: down.rate, + // 금액으로 정했을 때만 실어 보낸다 — 비율로 바꾸면 지워야 기준이 되돌아온다 + down_payment_amount: down.amount ?? null, installment_months: months, // ⚠️ `tax_exempt_type`·`has_biz_plate` 는 보내지 않는다 — 더 이상 묻지 않는 값이라 // 기본값을 실어 보내면 저장만 눌러도 예전 견적의 실구매가가 달라진다. @@ -326,9 +337,10 @@ function InputsTab({ quote, frozen, busy, setBusy, onDone, onFail }: SubProps) { <>
- - setDownPct(e.target.value)} /> - +