From e6b57ae4dcbb3994113514cee16e429ea9c74ee7 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:44:22 +0900 Subject: [PATCH] =?UTF-8?q?fix(=EA=B2=AC=EC=A0=81):=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20=ED=8C=9D=EC=97=85=EC=9D=84=20=EB=8B=A4=EC=8B=9C=20=EC=97=B4?= =?UTF-8?q?=EB=A9=B4=20=EC=84=A0=EC=88=98=EA=B8=88=20=EA=B8=88=EC=95=A1?= =?UTF-8?q?=EC=9D=B4=20=E3=80=8Cnull=E3=80=8D=EB=A1=9C=20=EB=9C=A8?= =?UTF-8?q?=EB=8D=98=20=EA=B2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기준을 비율로 되돌리고 저장하면 `down_payment_amount` 에 `null` 이 남는다. 그런데 화면이 `!== undefined` 로 판정해 그 `null` 을 **「금액으로 정했다」로 읽었다** — 그래서 다시 열면 금액 칸에 `String(null)` 인 **「null」이라는 글자가 그대로 찍히고** 비율 칸이 잠겼다(실제 제보). 계산 쪽에서는 이미 `!= null` 로 막아 두었는데 **화면 세 곳을 놓쳤다.** `DownPaymentFields`(모드 판정·초기값)·`ConfirmQuoteModal`·`QuoteEditModal` 전부 고친다. 실제 컴포넌트로 확인 — 저장값이 `{rate: 0.3, amount: null}` 일 때: 전: 금액 칸 「null」, 비율 잠김, 안내 「금액으로 정했습니다」 후: 비율 30 · 금액 15,017,550(자동), 안내 「비율로 정했습니다」 회귀 가드는 **값을 읽는 다섯 파일 전부**를 훑는다. 한 곳만 보게 두면 이번처럼 계산은 고치고 화면은 놓친다. 다섯 파일을 하나씩 되돌려 실제로 잡히는 것을 확인했다. Closes #290 --- .../src/__tests__/down-payment-null.test.ts | 66 +++++++++++++++++++ frontend/src/components/ConfirmQuoteModal.tsx | 3 +- frontend/src/components/DownPaymentFields.tsx | 20 ++++-- frontend/src/components/QuoteEditModal.tsx | 3 +- 4 files changed, 86 insertions(+), 6 deletions(-) create mode 100644 backend/src/__tests__/down-payment-null.test.ts diff --git a/backend/src/__tests__/down-payment-null.test.ts b/backend/src/__tests__/down-payment-null.test.ts new file mode 100644 index 0000000..9af40b0 --- /dev/null +++ b/backend/src/__tests__/down-payment-null.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +/** + * **`null` 은 「0원」이 아니라 「금액 기준을 푼 것」이다.** + * + * 선수금을 금액이 아니라 비율로 되돌리면, 저장할 때 `down_payment_amount: null` 을 보내 + * 값을 지운다. 그래서 **다시 열면 이 칸에 `null` 이 들어온다.** + * + * `!== undefined` 로 보면 그 `null` 이 「금액으로 정했다」로 읽혀 두 가지가 깨진다: + * · 화면 — 금액 칸에 **`String(null)` = 「null」이라는 글자**가 그대로 찍힌다(실제 제보) + * · 계산 — 선수금이 **0원**이 되어, 비율을 30% 로 되돌렸는데 견적서에 0원이 찍힌다 + * + * 이 파일은 그 실수가 되살아나는 것을 막는다. 실제로 한 번은 계산에서 잡았는데 + * **화면 세 곳을 놓쳤다** — 그래서 「값을 읽는 모든 곳」을 훑는다. + */ +const ROOT = path.resolve(__dirname, '../../..'); +const read = (rel: string) => readFileSync(path.join(ROOT, rel), 'utf8'); + +/** 선수금 금액을 읽는 곳들 — 여기서 `undefined` 만 보면 `null` 이 새어 들어간다. */ +const READERS = [ + 'frontend/src/components/DownPaymentFields.tsx', + 'frontend/src/components/ConfirmQuoteModal.tsx', + 'frontend/src/components/QuoteEditModal.tsx', + 'shared/pricing/quote.ts', + 'backend/src/services/quote-calc.ts', +]; + +describe('선수금 금액의 null 처리', () => { + it('🔴 금액을 읽는 곳 어디에서도 `!== undefined` 로 판정하지 않는다', () => { + const bad: string[] = []; + for (const f of READERS) { + /* + * 주석에는 「`!== undefined` 로 보면 안 된다」고 적을 수 있어야 한다 — + * 왜 이렇게 했는지가 코드 옆에 남아야 다음 사람이 되돌리지 않는다. + * 그래서 **주석을 걷어내고 실제 코드만** 본다. + */ + const src = read(f) + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/\/\/.*$/gm, ''); + for (const line of src.split('\n')) { + if (!/down_payment_amount|(? { + for (const f of READERS.slice(0, 3)) { + const src = read(f); + expect(src, f).toMatch(/amount'?\]?\s*!=\s*null/); + } + }); + + it('계산도 같은 규칙을 쓴다', () => { + expect(read('shared/pricing/quote.ts')).toContain('p.down_payment_amount != null'); + expect(read('backend/src/services/quote-calc.ts')).toContain('extra?.down_payment_amount != null'); + }); + + it('타입이 null 을 허용한다 — 안 그러면 어딘가에서 강제 캐스팅으로 덮인다', () => { + expect(read('shared/pricing/quote.ts')).toMatch(/down_payment_amount\?:\s*number \| null/); + expect(read('frontend/src/components/DownPaymentFields.tsx')).toMatch(/amount\?:\s*number \| null/); + }); +}); diff --git a/frontend/src/components/ConfirmQuoteModal.tsx b/frontend/src/components/ConfirmQuoteModal.tsx index c201a37..aaaa737 100644 --- a/frontend/src/components/ConfirmQuoteModal.tsx +++ b/frontend/src/components/ConfirmQuoteModal.tsx @@ -39,7 +39,8 @@ export function ConfirmQuoteModal({ quoteId, customerName, status, initialInputs 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 } : {}), + // `null` 은 「금액 기준을 푼 것」 — 금액 기준으로 되살리면 안 된다 + ...(init['down_payment_amount'] != null ? { amount: init['down_payment_amount'] as number } : {}), }) /** 비율↔금액을 서로 바꿔 보여 줄 기준 금액 — 서버 계산이 준 값을 쓴다 */ const [base, setBase] = useState(0) diff --git a/frontend/src/components/DownPaymentFields.tsx b/frontend/src/components/DownPaymentFields.tsx index 27cb126..c028c5a 100644 --- a/frontend/src/components/DownPaymentFields.tsx +++ b/frontend/src/components/DownPaymentFields.tsx @@ -15,8 +15,13 @@ export function DownPaymentFields({ base, rate, amount, disabled, onChange, Fiel base: number /** 저장된 비율(0~1) */ rate: number - /** 저장된 금액(원). 있으면 금액이 기준이다 */ - amount?: number + /** + * 저장된 금액(원). 있으면 금액이 기준이다. + * + * ⚠️ `null` 은 **「금액 기준을 푼 것」**이지 「0원」이 아니다. 저장할 때 기준을 풀면 + * `null` 을 보내 지우기 때문에, 다시 열면 이 값이 `null` 로 들어온다. + */ + amount?: number | null disabled?: boolean onChange: (next: { rate: number; amount?: number }) => void /** 라벨 감싸개 — 팝업마다 모양이 달라 밖에서 받는다 */ @@ -24,9 +29,16 @@ export function DownPaymentFields({ base, rate, amount, disabled, onChange, Fiel inputStyle: React.CSSProperties }) { /** 무엇을 기준으로 잡았나. 금액이 저장돼 있으면 금액, 아니면 비율. */ - const [mode, setMode] = useState<'rate' | 'amount' | null>(amount !== undefined ? 'amount' : (rate > 0 ? 'rate' : null)) + /* + * ⚠️ `!= null` 이다(`!== undefined` 가 아니다). + * + * 기준을 풀고 저장하면 `down_payment_amount` 에 `null` 이 남는다. `!== undefined` 로 보면 + * 그 `null` 이 「금액으로 정했다」로 읽혀, 칸에 **`String(null)` = 「null」이라는 글자가 + * 그대로 찍히고** 비율 칸이 잠긴다(실제 제보). + */ + const [mode, setMode] = useState<'rate' | 'amount' | null>(amount != null ? '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) : '') + const [won, setWon] = useState(amount != null ? String(amount) : '') // 기준이 되는 쪽이 바뀌면 잠긴 쪽을 다시 채운다 useEffect(() => { diff --git a/frontend/src/components/QuoteEditModal.tsx b/frontend/src/components/QuoteEditModal.tsx index 5a11e2a..2c1a50c 100644 --- a/frontend/src/components/QuoteEditModal.tsx +++ b/frontend/src/components/QuoteEditModal.tsx @@ -305,7 +305,8 @@ function InputsTab({ quote, frozen, busy, setBusy, onDone, onFail }: SubProps) { const inp = (quote.inputs ?? {}) as Record 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 } : {}), + // `null` 은 「금액 기준을 푼 것」 — 금액 기준으로 되살리면 안 된다 + ...(inp['down_payment_amount'] != null ? { amount: inp['down_payment_amount'] as number } : {}), }) const [months, setMonths] = useState((inp['installment_months'] as number) ?? 0) const [rates, setRates] = useState([])