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
5 changes: 4 additions & 1 deletion backend/src/routes/quotes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@ quotesRouter.get('/:id/total', rbac('SALES', 'ADMIN'), async (req: Request, res)
quote.model_code, (quote.selections ?? {}) as Record<string, string>, 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,
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions backend/src/services/contract-docgen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ export async function buildContractTokensFromQuote(quoteId: number): Promise<Con
tax_exempt_type: inp['tax_exempt_type'] as string | undefined,
}, {
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,
Expand Down
3 changes: 3 additions & 0 deletions backend/src/services/quote-calc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export type CustomerInput = {
/** 총견적서 견적단위 입력(선수금 비율·할부개월수·재량할인). */
export type QuoteExtraInput = {
down_payment_rate?: number; // 선수금 비율 (0~1)
down_payment_amount?: number | null; // 선수금 금액(원). null = 금액 기준을 풀고 비율로 되돌림
installment_months?: number; // 할부개월수 (0=일시불)
/**
* ⚠️ **옛 방식**: 0원 처리할 특장옵션 그룹코드. 새 견적에서는 쓰지 않는다.
Expand Down Expand Up @@ -125,6 +126,8 @@ export async function buildQuoteParams(
car_deposit: taxMap['car_deposit'] ?? 100_000,
body_deposit: taxMap['body_deposit'] ?? 400_000,
down_payment_rate: extra?.down_payment_rate ?? 0,
// 비율/금액 중 **하나만** 기준이 된다. 금액이 있으면 계산이 그쪽을 쓴다.
...(extra?.down_payment_amount != null ? { down_payment_amount: extra.down_payment_amount } : {}),
installment_months: months,
installment_rate: instRate ? Number(instRate.rate) : 0,
has_biz_plate: customer?.has_biz_plate ?? false,
Expand Down
16 changes: 14 additions & 2 deletions backend/src/services/quote-pdf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ export async function generateQuotePdf(quoteId: number): Promise<QuotePdfResult>
};
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,
Expand Down Expand Up @@ -178,7 +179,17 @@ export async function generateQuotePdf(quoteId: number): Promise<QuotePdfResult>

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)}`,
Expand Down Expand Up @@ -232,7 +243,8 @@ export async function generateQuotePdf(quoteId: number): Promise<QuotePdfResult>
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),
Expand Down
8 changes: 7 additions & 1 deletion doc-templates/quote-template.html
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,13 @@ <h2 class="sec">고객 정보</h2>
<tr><td class="lab">소상공인</td><td class="cval" colspan="2">{{ cust.isSosang }}</td></tr>
<tr><td class="lab">화물자동차 운송사업허가증</td><td class="cval" colspan="2">{{ cust.hasTransportLicense }}</td></tr>
<tr><td class="lab">경유차 폐차여부</td><td class="cval" colspan="2">{{ cust.dieselStatus }}</td></tr>
<tr><td class="lab">선수금</td><td class="cval" colspan="2">{{ cust.advanceRate }}</td></tr>
<!--
선수금은 **비율과 금액을 함께** 적는다. 영업은 「30%」로 말하고 고객은
「얼마 내면 되냐」를 묻는다. 한쪽만 적으면 나머지는 사람이 계산기를 두드린다.
비율은 절삭한 선수금에서 되돌려 구한 값이라 아래 금액과 서로 맞는다.
-->
<tr><td class="lab">선수금 (비율)</td><td class="cval" colspan="2">{{ cust.advanceRate }}</td></tr>
<tr><td class="lab">선수금 (금액)</td><td class="cval" colspan="2">{{ cust.advanceAmount }}</td></tr>
<tr class="grp"><td class="k">할부원금</td><td class="num" colspan="2">{{ inst.total }}</td></tr>
<tr><td class="k">이율</td><td class="num" colspan="2">{{ inst.interestRate }}</td></tr>
<tr><td class="k">할부이자</td><td class="num" colspan="2">{{ inst.interest }}</td></tr>
Expand Down
33 changes: 27 additions & 6 deletions frontend/src/components/ConfirmQuoteModal.tsx
Original file line number Diff line number Diff line change
@@ -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'

/**
* 견적서 생성 팝업 — **캐피탈 관련 입력만** 받는다 (선수금 비율 · 할부 개월수).
Expand Down Expand Up @@ -36,19 +37,31 @@ export function ConfirmQuoteModal({ quoteId, customerName, status, initialInputs
const init = initialInputs ?? {}
const isConfirmed = status !== 'draft'

const [downPct, setDownPct] = useState<string>(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<number>((init['installment_months'] as number) ?? 0)

const [rates, setRates] = useState<InstallmentRateOption[]>([])
const [busy, setBusy] = useState(false)
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` 는 **보내지 않는다.**
Expand Down Expand Up @@ -89,9 +102,17 @@ export function ConfirmQuoteModal({ quoteId, customerName, status, initialInputs
</div>
) : (
<>
<label style={s.label}>선수금 비율 <span style={s.unit}>(%)</span></label>
<input style={s.input} type="number" min={0} max={100} step={1} value={downPct}
onChange={(e) => setDownPct(e.target.value)} />
<DownPaymentFields
base={base} rate={down.rate} amount={down.amount}
onChange={setDown}
Field={({ label, children }) => (
<>
<label style={s.label}>{label}</label>
{children}
</>
)}
inputStyle={s.input}
/>

<label style={s.label}>할부 개월수 <span style={s.unit}>(개월 · 이율)</span></label>
<select style={s.input} value={months} onChange={(e) => setMonths(Number(e.target.value))}>
Expand Down
96 changes: 96 additions & 0 deletions frontend/src/components/DownPaymentFields.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { useEffect, useState } from 'react'

/**
* 선수금 입력 — **비율과 금액 중 하나만 고른다.**
*
* 영업은 「30%」로 말할 때도 있고 「1,500만원」으로 말할 때도 있다. 둘 다 열어 두면
* 서로 어긋난 값이 저장되고, 견적서에 「30% / 1,200만원」처럼 맞지 않는 두 줄이 나간다.
*
* 그래서 **먼저 적은 쪽이 기준이 되고 다른 쪽은 잠긴다.** 잠긴 칸은 비어 있지 않고
* 계산된 값이 채워져 보인다 — 얼마인지 알아야 고객에게 말할 수 있기 때문이다.
* 기준을 바꾸려면 적어 둔 칸을 비우면 둘 다 다시 열린다.
*/
export function DownPaymentFields({ base, rate, amount, disabled, onChange, Field, inputStyle }: {
/** 선수금과 할부원금이 나눠 갖는 몫(원). 서버 계산이 준 값을 그대로 쓴다 */
base: number
/** 저장된 비율(0~1) */
rate: number
/** 저장된 금액(원). 있으면 금액이 기준이다 */
amount?: number
disabled?: boolean
onChange: (next: { rate: number; amount?: number }) => void
/** 라벨 감싸개 — 팝업마다 모양이 달라 밖에서 받는다 */
Field: (p: { label: string; children: React.ReactNode }) => React.ReactElement
inputStyle: React.CSSProperties
}) {
/** 무엇을 기준으로 잡았나. 금액이 저장돼 있으면 금액, 아니면 비율. */
const [mode, setMode] = useState<'rate' | 'amount' | null>(amount !== undefined ? 'amount' : (rate > 0 ? 'rate' : null))
const [pct, setPct] = useState(rate > 0 ? String(Math.round(rate * 1000) / 10) : '')
const [won, setWon] = useState(amount !== undefined ? String(amount) : '')

// 기준이 되는 쪽이 바뀌면 잠긴 쪽을 다시 채운다
useEffect(() => {
if (mode === 'rate') {
const r = (Number(pct) || 0) / 100
setWon(String(Math.floor(base * r)))
} else if (mode === 'amount') {
const a = Math.min(Math.max(Number(won) || 0, 0), base)
setPct(base > 0 ? String(Math.floor((a / base) * 100)) : '0')
}
}, [mode, pct, won, base])

function editRate(v: string) {
setPct(v)
if (v.trim() === '') { setMode(null); setWon(''); onChange({ rate: 0 }); return }
setMode('rate')
onChange({ rate: (Number(v) || 0) / 100 }) // 금액은 보내지 않는다 — 비율이 기준
}

function editAmount(v: string) {
if (v.trim() === '') { setWon(''); setMode(null); setPct(''); onChange({ rate: 0 }); return }
/*
* 나눠 가질 몫을 넘기면 **화면에 적힌 숫자도 함께 눌러** 준다.
* 예전엔 저장만 눌리고 칸에는 친 그대로 남아, 「999,999,999원을 선수금으로 넣었다」고
* 읽히는데 실제로 저장된 값은 달랐다. 보이는 것과 저장되는 것이 다르면 안 된다.
*/
const a = Math.min(Math.max(Number(v) || 0, 0), base)
setWon(String(a))
setMode('amount')
onChange({ rate: base > 0 ? a / base : 0, amount: a })
}

const rateLocked = disabled || mode === 'amount'
const amountLocked = disabled || mode === 'rate'
const lockedStyle = { ...inputStyle, background: 'var(--card)', color: 'var(--muted)' }

return (
<>
<Field label="선수금 비율 (%)">
<input
style={rateLocked ? lockedStyle : inputStyle}
inputMode="decimal" value={pct} disabled={rateLocked}
placeholder={mode === 'amount' ? '금액에서 계산됨' : ''}
onChange={e => editRate(e.target.value)}
/>
</Field>
<Field label="선수금 금액 (원)">
<input
style={amountLocked ? lockedStyle : inputStyle}
inputMode="numeric" value={won} disabled={amountLocked}
placeholder={mode === 'rate' ? '비율에서 계산됨' : ''}
onChange={e => editAmount(e.target.value)}
/>
</Field>
{mode !== null && !disabled && (
<div style={hint}>
{mode === 'rate' ? '비율' : '금액'}로 정했습니다 — 다른 쪽은 자동으로 계산됩니다.
바꾸려면 적어 둔 칸을 비우세요.
</div>
)}
</>
)
}

const hint: React.CSSProperties = {
gridColumn: '1 / -1', fontSize: 'var(--fs-caption)', color: 'var(--muted)', marginTop: -4,
}
24 changes: 18 additions & 6 deletions frontend/src/components/QuoteEditModal.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<string, unknown>
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<number>((inp['installment_months'] as number) ?? 0)
const [rates, setRates] = useState<InstallmentRateOption[]>([])
/** 비율↔금액을 서로 바꿔 보여 줄 기준 금액 — 서버 계산이 준 값을 쓴다 */
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` 는 보내지 않는다 — 더 이상 묻지 않는 값이라
// 기본값을 실어 보내면 저장만 눌러도 예전 견적의 실구매가가 달라진다.
Expand All @@ -326,9 +337,10 @@ function InputsTab({ quote, frozen, busy, setBusy, onDone, onFail }: SubProps) {
<>
<div style={s.scroll}>
<div style={s.grid}>
<Field label="선수금 비율 (%)">
<input style={s.field} inputMode="numeric" value={downPct} onChange={e => setDownPct(e.target.value)} />
</Field>
<DownPaymentFields
base={base} rate={down.rate} amount={down.amount} disabled={frozen}
onChange={setDown} Field={Field} inputStyle={s.field}
/>
<Field label="할부 개월수">
<select style={s.field} value={months} onChange={e => setMonths(Number(e.target.value))}>
<option value={0}>일시불</option>
Expand Down
Loading