From 92a62050eb8f71ba15f2ae4d6ad3e0af747362b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A4=80=EC=84=B1=EC=9D=98=20Macbook=20Pro?= Date: Tue, 25 Aug 2026 10:34:45 +0900 Subject: [PATCH] =?UTF-8?q?feat(=EA=B2=AC=EC=A0=81):=20=EC=84=A0=EC=88=98?= =?UTF-8?q?=EA=B8=88=EC=9D=84=20=EB=B9=84=EC=9C=A8=20=EB=98=90=EB=8A=94=20?= =?UTF-8?q?=EA=B8=88=EC=95=A1=EC=9C=BC=EB=A1=9C=20=E2=80=94=20=ED=95=98?= =?UTF-8?q?=EB=82=98=EB=A5=BC=20=EC=A0=81=EC=9C=BC=EB=A9=B4=20=EB=8B=A4?= =?UTF-8?q?=EB=A5=B8=20=EC=AA=BD=EC=9D=80=20=EC=9E=A0=EA=B8=B0=EA=B3=A0=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EA=B3=84=EC=82=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 영업은 「30%」로 말할 때도 있고 「1,500만원」으로 말할 때도 있다. 비율만 받으면 나머지는 사람이 계산기를 두드려야 하고, 그러면 어긋난다. **입력** — 비율·금액 두 칸이 다 보이되 **먼저 적은 쪽이 기준**이 된다. 다른 칸은 잠기고 계산된 값이 채워져 보인다(얼마인지 알아야 고객에게 말한다). 기준을 바꾸려면 적어 둔 칸을 비우면 둘 다 다시 열린다. 몫을 넘는 금액은 **화면에 적힌 숫자까지 함께** 눌러 준다 — 보이는 것과 저장되는 것이 다르면 안 된다. **견적서** — 고객 정보에 두 줄로 적는다. 선수금 (비율) 33% 선수금 (금액) 16,519,305 원 비율은 **절삭한 선수금에서 되돌려 구한 값**이라 옆 금액과 반드시 맞는다. 저장된 비율을 그대로 적으면 금액으로 정한 견적에서 「30% / 1,200만원」처럼 안 맞는 두 줄이 나간다. 반올림이 아니라 **버림**이다 — 29.6%를 30%로 적으면 금액과 대조하는 사람이 「30%인데 왜 이 금액이냐」고 묻게 된다. **합계 일치** — 선수금을 **원 단위로 내리고** 할부원금은 빼서 구한다. 그래서 «선수금 + 할부원금 = 나눠 가질 몫» 이 언제나 성립한다. 예전엔 소수점째로 두고 표시할 때만 반올림해 1원이 어긋날 수 있었다. ⚠️ 실구매가는 바뀌지 않는다 — 선수금은 낼 시점이 갈릴 뿐 총액이 아니다(회귀 검증 통과). ⚠️ `down_payment_amount: null` 은 「0원」이 아니라 **「금액 기준을 풀고 비율로 되돌린다」**다. `!== undefined` 로 보면 선수금이 0원이 되어, 비율을 30%로 되돌렸는데 0원이 찍힌다. Closes #288 --- backend/src/routes/quotes.ts | 5 +- backend/src/services/contract-docgen.ts | 1 + backend/src/services/quote-calc.ts | 3 + backend/src/services/quote-pdf.ts | 16 ++- doc-templates/quote-template.html | 8 +- frontend/src/components/ConfirmQuoteModal.tsx | 33 +++++-- frontend/src/components/DownPaymentFields.tsx | 96 ++++++++++++++++++ frontend/src/components/QuoteEditModal.tsx | 24 +++-- shared/pricing/down-payment.test.ts | 98 +++++++++++++++++++ shared/pricing/quote.ts | 51 +++++++++- 10 files changed, 318 insertions(+), 17 deletions(-) create mode 100644 frontend/src/components/DownPaymentFields.tsx create mode 100644 shared/pricing/down-payment.test.ts 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)} /> - +