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
66 changes: 66 additions & 0 deletions backend/src/__tests__/down-payment-null.test.ts
Original file line number Diff line number Diff line change
@@ -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|(?<![\w])amount(?![\w])/.test(line)) continue;
if (/!==\s*undefined/.test(line)) bad.push(`${f}: ${line.trim()}`);
}
}
expect(bad, `null 이 새는 곳:\n${bad.join('\n')}`).toEqual([]);
});

it('화면이 저장값을 되읽을 때 null 을 「기준 없음」으로 본다', () => {
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/);
});
});
3 changes: 2 additions & 1 deletion frontend/src/components/ConfirmQuoteModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
20 changes: 16 additions & 4 deletions frontend/src/components/DownPaymentFields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,30 @@ 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
/** 라벨 감싸개 — 팝업마다 모양이 달라 밖에서 받는다 */
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))
/*
* ⚠️ `!= 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(() => {
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/QuoteEditModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,8 @@ function InputsTab({ quote, frozen, busy, setBusy, onDone, onFail }: SubProps) {
const inp = (quote.inputs ?? {}) as Record<string, unknown>
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<number>((inp['installment_months'] as number) ?? 0)
const [rates, setRates] = useState<InstallmentRateOption[]>([])
Expand Down