feat(landing): 거래처 사용법 6단계 + 화면 미리보기 섹션 추가
- 가입→검색→장바구니→승인→메일→정산까지 단계별 카드 - 장바구니 미리보기 + 자동발송 메일 미리보기 추가 - 우측 상단 회원가입/로그인 버튼은 기존 유지 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
// 발주 승인 시 발송될 거래명세표 메일을 그대로 시뮬레이션
|
||||
// (DB 없이 실제 운영 코드와 동일한 buildStatementHtml/Xlsx 사용)
|
||||
import nodemailer from "nodemailer";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const envPath = path.join(__dirname, "..", ".env.development");
|
||||
if (fs.existsSync(envPath)) {
|
||||
for (const line of fs.readFileSync(envPath, "utf-8").split(/\r?\n/)) {
|
||||
const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
|
||||
if (m) process.env[m[1]] ??= m[2].replace(/^["']|["']$/g, "");
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 운영 코드와 동일한 함수 (excel-statement.ts 미니 포팅) =====
|
||||
const fmt = (n) => Math.round(n);
|
||||
const escapeHtml = (s) => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
const formatNumber = (n) => n.toLocaleString("ko-KR");
|
||||
|
||||
function buildStatementXlsx(input) {
|
||||
const wb = XLSX.utils.book_new();
|
||||
const aoa = [];
|
||||
aoa.push(["거 래 명 세 표"]);
|
||||
aoa.push([]);
|
||||
aoa.push(["발주번호", input.orderNo, "", "발주일자", input.orderDate]);
|
||||
aoa.push([]);
|
||||
aoa.push(["[공급받는자]"]);
|
||||
aoa.push(["업체명", input.customer.companyName, "대표자", input.customer.ceoName ?? "-"]);
|
||||
aoa.push(["사업자번호", input.customer.bizNo ?? "-", "전화번호", input.customer.phone ?? "-"]);
|
||||
aoa.push([]);
|
||||
aoa.push(["[공급자]"]);
|
||||
aoa.push(["업체명", input.supplier.companyName, "계좌번호", input.supplier.bankAccount ?? "-"]);
|
||||
aoa.push(["전화번호", input.supplier.phone ?? "-", "이메일", input.supplier.email ?? "-"]);
|
||||
aoa.push([]);
|
||||
aoa.push(["순번", "품명", "구분", "수량", "단위", "단가", "공급가액", "세액", "합계"]);
|
||||
for (const it of input.items) {
|
||||
aoa.push([it.seq, it.itemName, it.isTaxFree ? "면세" : "과세", it.qty, it.unit || "EA",
|
||||
fmt(it.unitPrice), fmt(it.supplyAmount), fmt(it.vatAmount), fmt(it.totalAmount)]);
|
||||
}
|
||||
aoa.push([]);
|
||||
aoa.push(["", "", "", "", "", "면세 합계", fmt(input.totals.taxFree)]);
|
||||
aoa.push(["", "", "", "", "", "과세 공급가", fmt(input.totals.taxable)]);
|
||||
aoa.push(["", "", "", "", "", "세액 합계", fmt(input.totals.vat)]);
|
||||
aoa.push(["", "", "", "", "", "총 합계 (VAT포함)", fmt(input.totals.total)]);
|
||||
const ws = XLSX.utils.aoa_to_sheet(aoa);
|
||||
ws["!cols"] = [{ wch: 6 }, { wch: 28 }, { wch: 6 }, { wch: 8 }, { wch: 6 }, { wch: 12 }, { wch: 14 }, { wch: 12 }, { wch: 14 }];
|
||||
ws["!merges"] = [{ s: { r: 0, c: 0 }, e: { r: 0, c: 8 } }];
|
||||
XLSX.utils.book_append_sheet(wb, ws, "거래명세표");
|
||||
return XLSX.write(wb, { type: "buffer", bookType: "xlsx" });
|
||||
}
|
||||
|
||||
function buildStatementHtml(input) {
|
||||
const rows = input.items.map((it) => `
|
||||
<tr>
|
||||
<td style="text-align:center;border:1px solid #cbd5e1;padding:7px">${it.seq}</td>
|
||||
<td style="border:1px solid #cbd5e1;padding:7px">${escapeHtml(it.itemName)}</td>
|
||||
<td style="text-align:center;color:${it.isTaxFree ? "#7c3aed" : "#e11d48"};border:1px solid #cbd5e1;padding:7px">${it.isTaxFree ? "면세" : "과세"}</td>
|
||||
<td style="text-align:right;border:1px solid #cbd5e1;padding:7px">${it.qty}</td>
|
||||
<td style="text-align:center;border:1px solid #cbd5e1;padding:7px">${escapeHtml(it.unit || "EA")}</td>
|
||||
<td style="text-align:right;border:1px solid #cbd5e1;padding:7px">${formatNumber(fmt(it.unitPrice))}</td>
|
||||
<td style="text-align:right;border:1px solid #cbd5e1;padding:7px">${formatNumber(fmt(it.supplyAmount))}</td>
|
||||
<td style="text-align:right;border:1px solid #cbd5e1;padding:7px">${it.isTaxFree ? "-" : formatNumber(fmt(it.vatAmount))}</td>
|
||||
<td style="text-align:right;font-weight:600;border:1px solid #cbd5e1;padding:7px">${formatNumber(fmt(it.totalAmount))}</td>
|
||||
</tr>`).join("");
|
||||
return `<!doctype html><html lang="ko"><body style="font-family:'Apple SD Gothic Neo','Malgun Gothic',sans-serif;color:#0f172a;padding:24px;background:#fff">
|
||||
<h2 style="text-align:center;letter-spacing:8px;margin:0 0 16px">거 래 명 세 표</h2>
|
||||
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:16px">
|
||||
<div><div><b>발주번호</b> ${escapeHtml(input.orderNo)}</div><div><b>발주일자</b> ${escapeHtml(input.orderDate)}</div></div>
|
||||
<div style="text-align:right"><div><b>공급자</b> ${escapeHtml(input.supplier.companyName)}</div>
|
||||
<div>${escapeHtml(input.supplier.phone ?? "")} · ${escapeHtml(input.supplier.email ?? "")}</div></div>
|
||||
</div>
|
||||
<div style="border:1px solid #cbd5e1;padding:10px;margin-bottom:14px;font-size:13px">
|
||||
<b>${escapeHtml(input.customer.companyName)}</b> 귀하
|
||||
${input.customer.ceoName ? ` · 대표 ${escapeHtml(input.customer.ceoName)}` : ""}
|
||||
${input.customer.bizNo ? ` · 사업자번호 ${escapeHtml(input.customer.bizNo)}` : ""}
|
||||
</div>
|
||||
<table style="width:100%;border-collapse:collapse;font-size:13px">
|
||||
<thead><tr style="background:#f1f5f9">
|
||||
<th style="border:1px solid #cbd5e1;padding:8px">순번</th>
|
||||
<th style="border:1px solid #cbd5e1;padding:8px">품명</th>
|
||||
<th style="border:1px solid #cbd5e1;padding:8px">구분</th>
|
||||
<th style="border:1px solid #cbd5e1;padding:8px">수량</th>
|
||||
<th style="border:1px solid #cbd5e1;padding:8px">단위</th>
|
||||
<th style="border:1px solid #cbd5e1;padding:8px">단가</th>
|
||||
<th style="border:1px solid #cbd5e1;padding:8px">공급가액</th>
|
||||
<th style="border:1px solid #cbd5e1;padding:8px">세액</th>
|
||||
<th style="border:1px solid #cbd5e1;padding:8px">합계</th>
|
||||
</tr></thead>
|
||||
<tbody style="font-variant-numeric:tabular-nums">${rows}</tbody>
|
||||
</table>
|
||||
<table style="margin-top:14px;margin-left:auto;font-size:13px;font-variant-numeric:tabular-nums">
|
||||
<tr><td style="padding:4px 12px;color:#7c3aed">면세 합계</td><td style="padding:4px 0;text-align:right;min-width:140px">₩ ${formatNumber(fmt(input.totals.taxFree))}</td></tr>
|
||||
<tr><td style="padding:4px 12px;color:#e11d48">과세 공급가</td><td style="padding:4px 0;text-align:right">₩ ${formatNumber(fmt(input.totals.taxable))}</td></tr>
|
||||
<tr><td style="padding:4px 12px">세액 합계</td><td style="padding:4px 0;text-align:right">₩ ${formatNumber(fmt(input.totals.vat))}</td></tr>
|
||||
<tr><td style="padding:8px 12px;font-weight:700;border-top:2px solid #0f172a">총 합계 (VAT 포함)</td><td style="padding:8px 0;text-align:right;font-weight:700;border-top:2px solid #0f172a">₩ ${formatNumber(fmt(input.totals.total))}</td></tr>
|
||||
</table>
|
||||
<div style="margin-top:32px;padding-top:16px;border-top:1px solid #e2e8f0;font-size:12px;color:#475569">위와 같이 계산합니다. — 모모유통</div>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
// ===== 가짜 발주 데이터 (스크린샷 첨부 거래명세표 그대로) =====
|
||||
const items = [
|
||||
{ seq: 1, itemName: "M 유정란", unit: "EA", qty: 30, unitPrice: 10000, isTaxFree: true },
|
||||
{ seq: 2, itemName: "M 꽃계탕", unit: "EA", qty: 20, unitPrice: 4500, isTaxFree: true },
|
||||
{ seq: 3, itemName: "빨강 탈취제", unit: "EA", qty: 11, unitPrice: 9200, isTaxFree: false },
|
||||
{ seq: 4, itemName: "파랑 탈취제", unit: "EA", qty: 11, unitPrice: 9200, isTaxFree: false },
|
||||
{ seq: 5, itemName: "초록 탈취제", unit: "EA", qty: 3, unitPrice: 9200, isTaxFree: false },
|
||||
];
|
||||
for (const it of items) {
|
||||
const total = Math.round(it.unitPrice * it.qty);
|
||||
if (it.isTaxFree) { it.supplyAmount = total; it.vatAmount = 0; it.totalAmount = total; }
|
||||
else { const s = Math.round(total / 1.1); it.supplyAmount = s; it.vatAmount = total - s; it.totalAmount = total; }
|
||||
}
|
||||
const totals = items.reduce((a, it) => ({
|
||||
supply: a.supply + it.supplyAmount,
|
||||
vat: a.vat + it.vatAmount,
|
||||
total: a.total + it.totalAmount,
|
||||
taxFree: a.taxFree + (it.isTaxFree ? it.supplyAmount : 0),
|
||||
taxable: a.taxable + (it.isTaxFree ? 0 : it.supplyAmount),
|
||||
}), { supply: 0, vat: 0, total: 0, taxFree: 0, taxable: 0 });
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const stmt = {
|
||||
orderNo: `ORD-${today.replace(/-/g, "")}-TEST`,
|
||||
orderDate: today,
|
||||
customer: { companyName: "수원거래처(테스트)", ceoName: "박철현", bizNo: "123-45-67890", phone: "010-1234-5678" },
|
||||
supplier: {
|
||||
companyName: "모모유통",
|
||||
bankAccount: process.env.MOMO_BANK_ACCOUNT,
|
||||
phone: process.env.MOMO_PHONE,
|
||||
email: process.env.SMTP_FROM ?? "chpark@coa-soft.com",
|
||||
},
|
||||
items, totals,
|
||||
};
|
||||
|
||||
const to = process.argv[2] || "chpark@wace.me";
|
||||
const port = Number(process.env.SMTP_PORT || 465);
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST, port,
|
||||
secure: port === 465,
|
||||
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
|
||||
tls: { rejectUnauthorized: false },
|
||||
});
|
||||
|
||||
console.log("[stmt-mail] sending to:", to);
|
||||
const html = buildStatementHtml(stmt);
|
||||
const xlsx = buildStatementXlsx(stmt);
|
||||
|
||||
const info = await transporter.sendMail({
|
||||
from: process.env.SMTP_FROM,
|
||||
to,
|
||||
subject: `[모모유통] 발주 ${stmt.orderNo} 승인되었습니다 (테스트)`,
|
||||
html,
|
||||
attachments: [{
|
||||
filename: `거래명세표_${stmt.orderNo}.xlsx`,
|
||||
content: xlsx,
|
||||
contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
}],
|
||||
});
|
||||
console.log("[stmt-mail] ✔ sent:", info.messageId);
|
||||
console.log("[stmt-mail] response:", info.response);
|
||||
console.log("[stmt-mail] totals:", { taxFree: totals.taxFree, taxable: totals.taxable, vat: totals.vat, total: totals.total });
|
||||
Reference in New Issue
Block a user