diff --git a/backend/src/__tests__/vehicle-only.test.ts b/backend/src/__tests__/vehicle-only.test.ts index e944259..e8de282 100644 --- a/backend/src/__tests__/vehicle-only.test.ts +++ b/backend/src/__tests__/vehicle-only.test.ts @@ -129,3 +129,46 @@ describe('용어', () => { expect(bad, `「얹다」가 남은 곳: ${bad.join(', ')}`).toEqual([]); }); }); + +describe('차량만 견적에는 계약서가 없다', () => { + const SALES = read('frontend/src/pages/SalesPage.tsx'); + const DOCGEN = read('backend/src/services/contract-docgen.ts'); + const CONTRACT = read('backend/src/services/contract.ts'); + + it('🔴 서버가 막는다 — 화면만 막으면 API 로 그대로 만들어진다', () => { + // 계약서는 고객에게 나가는 문서다. 두 겹으로 막는다. + expect(DOCGEN).toContain('export async function assertContractable'); + for (const [name, src] of [['계약서 렌더', DOCGEN], ['전자서명 발송', CONTRACT], ['서면계약 등록', CONTRACT]] as const) { + expect(src, name).toContain('assertContractable(quoteId)'); + } + // 세 길이 모두 같은 문을 지나야 한다 + expect((CONTRACT.match(/assertContractable\(quoteId\)/g) ?? []).length).toBe(2); + }); + + it('🔴 계약 버튼 넷이 잠긴다 — 자리는 지킨다', () => { + /* + * 버튼을 없애면 「계약서가 어디 갔지」가 된다. 회색으로 남겨 + * 눌러 보면 왜 안 되는지 알 수 있게 한다. + */ + expect(SALES).toMatch(/const noContract = isVehicleOnly\(q\)/); + expect(SALES).toMatch(/noContractWhy/); + // 계약서 생성 · 서명 요청 · 서명본 등록 + expect((SALES.match(/disabled=\{[^}]*noContract[^}]*\}/g) ?? []).length).toBeGreaterThanOrEqual(3); + }); + + it('메일 전달은 **막지 않는다** — 견적서는 보낼 수 있어야 한다', () => { + // 계약서만 없는 것이지 견적서가 없는 것이 아니다 + const i = SALES.indexOf('메일 전달'); + const around = SALES.slice(Math.max(0, i - 900), i); + expect(around).toMatch(/disabled=\{!q\.customer\?\.email\}/); + }); + + it('메일 팝업에서 계약서 첨부칸을 아예 띄우지 않는다', () => { + // 체크할 수 없는 칸을 회색으로 남기면 「왜 안 되지」를 묻게 된다 — 원래 없는 서류다 + const M = read('frontend/src/components/EmailSendModal.tsx'); + expect(M).toMatch(/const canAttachContract = !noContract/); + expect(M).toMatch(/\{canAttachContract && \(/); + // 보낼 때도 그 값을 쓴다 — 화면만 감추고 실제로는 첨부되면 안 된다 + expect(M).toContain('include_contract: attachContract'); + }); +}); diff --git a/backend/src/services/contract-docgen.ts b/backend/src/services/contract-docgen.ts index 0a76a91..a461267 100644 --- a/backend/src/services/contract-docgen.ts +++ b/backend/src/services/contract-docgen.ts @@ -471,7 +471,25 @@ async function renderFromTokens(tokens: ContractTokens): Promise<{ pdf: Buffer; } /** 견적 → 계약서 PDF (즉석 렌더, 저장 없음). 영업페이지 미리보기·이메일 첨부용. */ +/** + * **차량만 견적에는 계약서가 없다.** + * + * 이 문서는 「특장 매매 및 구조변경 계약서」다. 특장을 장착하지 않는 거래에 쓰면 + * 특장 조항과 금액칸이 공란인 계약서가 고객에게 나간다. + * + * 화면에서도 버튼을 잠그지만, **서버가 막지 않으면 API 로 그대로 만들어진다.** + * 계약서는 고객에게 나가는 문서라 두 겹으로 막는다. + */ +export async function assertContractable(quoteId: number): Promise { + const q = await prisma?.quote.findUnique({ where: { id: quoteId }, select: { inputs: true } }); + const inp = (q?.inputs ?? {}) as Record; + if (inp['body_only'] !== true && inp['vehicle_only'] === true) { + throw new ContractDocError('차량만 견적은 특장 매매계약이 아니라 계약서를 만들지 않습니다.'); + } +} + export async function renderContractPdfForQuote(quoteId: number): Promise<{ pdf: Buffer; filename: string; pages: number; warnings: string[] }> { + await assertContractable(quoteId); const tokens = await buildContractTokensFromQuote(quoteId); const { pdf, pages, warnings } = await renderFromTokens(tokens); const who = tokens.buyer_name ? `_${tokens.buyer_name}` : ''; diff --git a/backend/src/services/contract.ts b/backend/src/services/contract.ts index fb374f1..d7e9983 100644 --- a/backend/src/services/contract.ts +++ b/backend/src/services/contract.ts @@ -12,7 +12,7 @@ import { prisma } from '../lib/prisma.js'; import { archiveCustomerDoc } from './doc-archive.js'; import { docStorageDir } from '../lib/soffice.js'; import { type ContractInput } from './contract-pdf.js'; -import { renderContractPdfForQuote, isCorporateContract } from './contract-docgen.js'; +import { renderContractPdfForQuote, isCorporateContract, assertContractable } from './contract-docgen.js'; import { freezeQuoteDocs } from './doc-freeze.js'; import { findSignPositions } from './sign-positions.js'; import { generateQuotePdf } from './quote-pdf.js'; @@ -106,6 +106,8 @@ export async function buildContractInput(quoteId: number): Promise<{ input: Cont * 전자서명용 계약서(placeholder) + 견적서 동봉(영업 프로세스)을 함께 보낸다. */ export async function sendContract(quoteId: number, signingMethod: SigningMethod): Promise { + // 차량만 견적에는 계약서가 없다 — 서명을 요청할 문서 자체가 없다 + await assertContractable(quoteId); const p = db(); // ── 비용 안전장치 ── 서명요청 1건마다 과금된다. @@ -505,6 +507,9 @@ export async function registerPaperContract( ): Promise { const p = db(); + // 차량만 견적에는 계약서가 없다 — 종이로도 성립하지 않는다 + await assertContractable(quoteId); + const q = await p.quote.findUnique({ where: { id: quoteId }, select: { status: true } }); if (!q) throw new ContractError('견적을 찾을 수 없습니다', 'NOT_FOUND'); // 확정 전 견적은 계약이 성립할 수 없다. 확정 이후 단계(배정·주문)는 이미 지나간 뒤라 손대지 않는다. diff --git a/frontend/src/components/EmailSendModal.tsx b/frontend/src/components/EmailSendModal.tsx index e19c1a0..1788d54 100644 --- a/frontend/src/components/EmailSendModal.tsx +++ b/frontend/src/components/EmailSendModal.tsx @@ -3,15 +3,25 @@ import { BTN } from '../styles/buttons' import { sendQuoteEmail } from '../api/email' /** 견적서(+계약서) 이메일 발송 모달. to 비우면 등록된 고객 이메일로 발송. */ -export function EmailSendModal({ quoteId, customerName, defaultTo, onClose }: { +export function EmailSendModal({ quoteId, customerName, defaultTo, onClose, noContract}: { quoteId: number customerName?: string defaultTo?: string onClose: () => void + + /** 차량만 견적 — 계약서가 없어 첨부 선택칸을 띄우지 않는다 */ + noContract?: boolean }) { const [to, setTo] = useState(defaultTo ?? '') // 기본은 견적서만 — 계약서는 필요할 때만 체크해서 보낸다 const [includeContract, setIncludeContract] = useState(false) + /* + * 차량만 견적에는 **계약서가 없다**(「특장 매매 및 구조변경 계약서」라 맞지 않는다). + * 체크칸을 아예 없앤다 — 체크할 수 없는 칸을 회색으로 남겨 두면 + * 「왜 안 되지」를 묻게 되고, 답은 「원래 없는 서류」다. + */ + const canAttachContract = !noContract + const attachContract = canAttachContract && includeContract const [message, setMessage] = useState('') const [sending, setSending] = useState(false) const [done, setDone] = useState(null) @@ -23,7 +33,7 @@ export function EmailSendModal({ quoteId, customerName, defaultTo, onClose }: { const r = await sendQuoteEmail(quoteId, { to: to.trim() || undefined, message: message.trim() || undefined, - include_contract: includeContract, + include_contract: attachContract, }) setDone(`${r.to} 로 발송됨 (${r.attachments.join(', ')})`) } catch (e) { @@ -52,15 +62,17 @@ export function EmailSendModal({ quoteId, customerName, defaultTo, onClose }: { setTo(e.target.value)} /> + {canAttachContract && ( + )}