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
43 changes: 43 additions & 0 deletions backend/src/__tests__/vehicle-only.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
18 changes: 18 additions & 0 deletions backend/src/services/contract-docgen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,7 +471,25 @@ async function renderFromTokens(tokens: ContractTokens): Promise<{ pdf: Buffer;
}

/** 견적 → 계약서 PDF (즉석 렌더, 저장 없음). 영업페이지 미리보기·이메일 첨부용. */
/**
* **차량만 견적에는 계약서가 없다.**
*
* 이 문서는 「특장 매매 및 구조변경 계약서」다. 특장을 장착하지 않는 거래에 쓰면
* 특장 조항과 금액칸이 공란인 계약서가 고객에게 나간다.
*
* 화면에서도 버튼을 잠그지만, **서버가 막지 않으면 API 로 그대로 만들어진다.**
* 계약서는 고객에게 나가는 문서라 두 겹으로 막는다.
*/
export async function assertContractable(quoteId: number): Promise<void> {
const q = await prisma?.quote.findUnique({ where: { id: quoteId }, select: { inputs: true } });
const inp = (q?.inputs ?? {}) as Record<string, unknown>;
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}` : '';
Expand Down
7 changes: 6 additions & 1 deletion backend/src/services/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -106,6 +106,8 @@ export async function buildContractInput(quoteId: number): Promise<{ input: Cont
* 전자서명용 계약서(placeholder) + 견적서 동봉(영업 프로세스)을 함께 보낸다.
*/
export async function sendContract(quoteId: number, signingMethod: SigningMethod): Promise<PurchaseContract> {
// 차량만 견적에는 계약서가 없다 — 서명을 요청할 문서 자체가 없다
await assertContractable(quoteId);
const p = db();

// ── 비용 안전장치 ── 서명요청 1건마다 과금된다.
Expand Down Expand Up @@ -505,6 +507,9 @@ export async function registerPaperContract(
): Promise<PurchaseContract> {
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');
// 확정 전 견적은 계약이 성립할 수 없다. 확정 이후 단계(배정·주문)는 이미 지나간 뒤라 손대지 않는다.
Expand Down
18 changes: 15 additions & 3 deletions frontend/src/components/EmailSendModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null)
Expand All @@ -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) {
Expand Down Expand Up @@ -52,15 +62,17 @@ export function EmailSendModal({ quoteId, customerName, defaultTo, onClose }: {
<label style={s.label}>받는 사람</label>
<input style={s.input} value={to} placeholder="비우면 등록된 고객 이메일로 발송" onChange={(e) => setTo(e.target.value)} />

{canAttachContract && (
<label style={s.check}>
<input type="checkbox" checked={includeContract} onChange={(e) => setIncludeContract(e.target.checked)} />
계약서도 함께 첨부 (미체크 시 견적서만)
</label>
)}

<label style={s.label}>메시지</label>
<textarea style={s.textarea} rows={4} value={message} placeholder="비우면 기본 안내문으로 발송" onChange={(e) => setMessage(e.target.value)} />

<div style={s.note}>※ 견적서{includeContract ? '·계약서' : ''} PDF 가 첨부됩니다. 전자서명은 별도(계약발송).</div>
<div style={s.note}>※ 견적서{attachContract ? '·계약서' : ''} PDF 가 첨부됩니다.{canAttachContract ? ' 전자서명은 별도(계약발송).' : ' 차량만 견적이라 계약서는 없습니다.'}</div>
{err && <div style={s.err}>{err}</div>}
<button style={s.primary} onClick={handleSend} disabled={sending}>{sending ? '발송 중…' : '발송'}</button>
</div>
Expand Down
35 changes: 23 additions & 12 deletions frontend/src/pages/SalesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { QuoteKindTag } from '../components/QuoteKindTag'
import { openPdf, reservePdfTab, openPdfIn, closeReservedTab } from '../lib/openPdf'
import { computeHidden, computeDisabledGroups, sanitizeSelections } from '../lib/optionRules'
import { buildLiveTotal } from '../lib/liveQuote'
import { mapBizType, customerEditValues, isBodyOnly } from '../lib/quoteCustomer'
import { mapBizType, customerEditValues, isBodyOnly, isVehicleOnly } from '../lib/quoteCustomer'
import type { CustomerInfo, ApiPricingBundle, ApiQuote, ApiOrder } from '@shared/types/index'
import type { PricingResult, PricingOk } from '@shared/pricing/core'
import { calcPrice, assembleOptionSum, TAKBAE_RATE, DIESEL_CONVERSION_SUBSIDY } from '@shared/pricing/core'
Expand Down Expand Up @@ -136,7 +136,7 @@ function MyListView() {
// 확인 팝업 안에서 지역을 고칠 수 있어야 한다(보조금이 걸린 값)
const [regions, setRegions] = useState<string[]>([])
useEffect(() => { fetchRegions().then(setRegions).catch(() => setRegions([])) }, [])
const [emailQuote, setEmailQuote] = useState<{ id: number; customerName?: string } | null>(null)
const [emailQuote, setEmailQuote] = useState<{ id: number; customerName?: string; noContract?: boolean } | null>(null)
const [confirmQuoteModal, setConfirmQuoteModal] = useState<
{ id: number; customerName?: string; status: string; inputs?: Record<string, unknown>; customer?: ApiQuote['customer'] } | null
>(null)
Expand Down Expand Up @@ -302,6 +302,7 @@ function MyListView() {
<EmailSendModal
quoteId={emailQuote.id}
customerName={emailQuote.customerName}
noContract={emailQuote.noContract}
onClose={() => setEmailQuote(null)}
/>
)}
Expand Down Expand Up @@ -473,6 +474,13 @@ function MyListView() {
</tr>
{isOpen && rows.map(q => {
const order = orderByQuote.get(q.id)
/*
* 차량만 견적에는 **계약서가 없다.** 지금 계약서는
* 「특장 매매 및 구조변경 계약서」라 특장이 없는 거래에 맞지 않는다.
* 버튼은 자리를 지키되 누르지 못하게 둔다 — 사라지면 「계약서가 어디 갔지」가 된다.
*/
const noContract = isVehicleOnly(q)
const noContractWhy = '차량만 견적은 특장 매매계약이 아니라 계약서를 만들지 않습니다'
return (
<tr key={q.id}>
<td style={lv.td}>{q.quote_no ?? `#${q.id}`}<QuoteKindTag quote={q} /></td>
Expand Down Expand Up @@ -554,9 +562,9 @@ function MyListView() {
다 채워져 있어도 한 번은 보여 준다 — 계약서에 그대로 박히는 값이라서.
*/}
<button
style={q.status === 'draft' ? { ...lv.pdfBtn, opacity: 0.45, cursor: 'not-allowed' } : lv.pdfBtn}
disabled={q.status === 'draft'}
title={q.status === 'draft'
style={(q.status === 'draft' || noContract) ? { ...lv.pdfBtn, opacity: 0.45, cursor: 'not-allowed' } : lv.pdfBtn}
disabled={q.status === 'draft' || noContract}
title={noContract ? noContractWhy : q.status === 'draft'
? '견적서를 먼저 만들어야 계약서를 만들 수 있습니다'
: '계약 정보를 확인하고 특장 매매계약서를 만듭니다'}
onClick={() => { setPrepErr(''); setContractPrep({ quote: q, next: 'pdf' }) }}
Expand All @@ -579,16 +587,18 @@ function MyListView() {
style={q.customer?.email ? lv.pdfBtn : BTN.rowMuted}
disabled={!q.customer?.email}
title={q.customer?.email
? '참고용 — 견적서·계약서 PDF 를 고객 메일로 전달합니다 (서명 요청 아님)'
? (noContract
? '참고용 — 견적서 PDF 를 고객 메일로 전달합니다 (차량만 견적이라 계약서는 없습니다)'
: '참고용 — 견적서·계약서 PDF 를 고객 메일로 전달합니다 (서명 요청 아님)')
: '고객 이메일이 없어 메일을 보낼 수 없습니다. 「고객정보」에서 이메일을 입력하세요.'}
onClick={() => setEmailQuote({ id: q.id, customerName: q.customer?.name ?? undefined })}
onClick={() => setEmailQuote({ id: q.id, customerName: q.customer?.name ?? undefined, noContract })}
>메일 전달</button>
)}
{canSign && (
<button
style={q.status === 'draft' ? { ...lv.sendBtn, opacity: 0.4, cursor: 'not-allowed' } : lv.sendBtn}
disabled={q.status === 'draft'}
title={q.status === 'draft' ? '견적서 생성 후 서명을 요청할 수 있습니다' : '고객에게 전자서명을 요청합니다 — 진행상태가 기록됩니다'}
style={(q.status === 'draft' || noContract) ? { ...lv.sendBtn, opacity: 0.4, cursor: 'not-allowed' } : lv.sendBtn}
disabled={q.status === 'draft' || noContract}
title={noContract ? noContractWhy : q.status === 'draft' ? '견적서 생성 후 서명을 요청할 수 있습니다' : '고객에게 전자서명을 요청합니다 — 진행상태가 기록됩니다'}
onClick={() => {
setPrepErr('')
// 계약서에 필요한 값이 비어 있으면 확인 팝업부터 — 서명은 그 계약서를 보내는 일이다
Expand All @@ -611,8 +621,9 @@ function MyListView() {
*/}
{canSign && q.status === 'confirmed' && (
<button
style={lv.sendBtn}
title="종이로 체결한 계약서 서명본을 올려 계약완료로 만듭니다"
style={noContract ? { ...lv.sendBtn, opacity: 0.4, cursor: 'not-allowed' } : lv.sendBtn}
disabled={noContract}
title={noContract ? noContractWhy : '종이로 체결한 계약서 서명본을 올려 계약완료로 만듭니다'}
onClick={() => { setPaperErr(''); setPaperFor(q) }}
>서명본 등록</button>
)}
Expand Down